From 9e1b3c9b8507f4371672e4be7c4ec1378660aa8b Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:42:07 -0800 Subject: [PATCH 01/16] Python: .NET: Updated package version and small fix (#1911) * Removed public key * Updated package version * Updated Python package versions --- dotnet/nuget/nuget-package.props | 6 ++-- .../Microsoft.Agents.AI.csproj | 2 +- python/CHANGELOG.md | 25 ++++++++++++++- python/packages/a2a/pyproject.toml | 2 +- python/packages/anthropic/pyproject.toml | 2 +- python/packages/azure-ai/pyproject.toml | 2 +- python/packages/copilotstudio/pyproject.toml | 2 +- python/packages/core/pyproject.toml | 2 +- python/packages/devui/pyproject.toml | 2 +- python/packages/lab/pyproject.toml | 2 +- python/packages/mem0/pyproject.toml | 2 +- python/packages/purview/pyproject.toml | 2 +- python/packages/redis/pyproject.toml | 2 +- python/pyproject.toml | 2 +- python/uv.lock | 32 ++++++++++++------- 15 files changed, 60 insertions(+), 27 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index c761802029..cc1c3b84ac 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251028.1 - $(VersionPrefix)-preview.251028.1 - 1.0.0-preview.251028.1 + $(VersionPrefix)-$(VersionSuffix).251104.1 + $(VersionPrefix)-preview.251104.1 + 1.0.0-preview.251104.1 Debug;Release;Publish true diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index a560dece67..59345d21ae 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -32,7 +32,7 @@ - + diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 29561bf511..c86947b527 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251104] - 2025-11-04 + +### Added + +- Introducing the Anthropic Client ([#1819](https://github.com/microsoft/agent-framework/pull/1819)) + +### Changed + +- [BREAKING] Consolidate workflow run APIs ([#1723](https://github.com/microsoft/agent-framework/pull/1723)) +- [BREAKING] Remove request_type param from ctx.request_info() ([#1824](https://github.com/microsoft/agent-framework/pull/1824)) +- [BREAKING] Cleanup of dependencies ([#1803](https://github.com/microsoft/agent-framework/pull/1803)) +- [BREAKING] Replace `RequestInfoExecutor` with `request_info` API and `@response_handler` ([#1466](https://github.com/microsoft/agent-framework/pull/1466)) +- Azure AI Search Support Update + Refactored Samples & Unit Tests ([#1683](https://github.com/microsoft/agent-framework/pull/1683)) +- Lab: Updates to GAIA module ([#1763](https://github.com/microsoft/agent-framework/pull/1763)) + +### Fixed + +- Azure AI `top_p` and `temperature` parameters fix ([#1839](https://github.com/microsoft/agent-framework/pull/1839)) +- Ensure agent thread is part of checkpoint ([#1756](https://github.com/microsoft/agent-framework/pull/1756)) +- Fix middleware and cleanup confusing function ([#1865](https://github.com/microsoft/agent-framework/pull/1865)) +- Fix type compatibility check ([#1753](https://github.com/microsoft/agent-framework/pull/1753)) + ## [1.0.0b251028] - 2025-10-28 ### Added @@ -124,7 +146,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251104...HEAD +[1.0.0b251104]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...python-1.0.0b251104 [1.0.0b251028]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251016...python-1.0.0b251028 [1.0.0b251016]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251007...python-1.0.0b251016 [1.0.0b251007]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251001...python-1.0.0b251007 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index c31e219926..058a843523 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 4e620c7595..65f3419276 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 2cd455c934..e68fdb0b66 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index b3325c76b5..7d2b927201 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 71855049a4..c4cef634a3 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 1b22574ed1..02d1e7de0a 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 8b7f8f3c96..d0a91e4147 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework" authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 93df61461e..a59acf5e86 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 4a498ac957..e7f120a23a 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 877b45bbc9..3e359b522e 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/pyproject.toml b/python/pyproject.toml index 6280b3d37b..5b7d8fee8d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251028" +version = "1.0.0b251104" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/uv.lock b/python/uv.lock index c19ba2cc89..fdcc316836 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -73,7 +73,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { virtual = "." } dependencies = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -160,7 +160,7 @@ docs = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -175,7 +175,7 @@ requires-dist = [ [[package]] name = "agent-framework-anthropic" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -190,7 +190,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/azure-ai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -207,7 +207,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -222,7 +222,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/core" } dependencies = [ { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -274,7 +274,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-devui" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -308,7 +308,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-lab" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -399,7 +399,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -414,7 +414,7 @@ requires-dist = [ [[package]] name = "agent-framework-purview" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -431,7 +431,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b251028" +version = "1.0.0b251104" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2039,6 +2039,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, @@ -2048,6 +2050,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, @@ -2057,6 +2061,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -2066,6 +2072,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -2073,6 +2081,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] From d5040236c9f6654bdf3cd3d061adb5a59a85c3f2 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 5 Nov 2025 07:49:34 +0900 Subject: [PATCH 02/16] Fix mcp tool cloning for handoff pattern (#1883) --- .../agent_framework/_workflows/_handoff.py | 10 +++++- .../core/tests/workflow/test_handoff.py | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index c98d9e752c..3c5995aeaf 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -80,6 +80,14 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent: options = agent.chat_options middleware = list(agent.middleware or []) + # Reconstruct the original tools list by combining regular tools with MCP tools. + # ChatAgent.__init__ separates MCP tools into _local_mcp_tools during initialization, + # so we need to recombine them here to pass the complete tools list to the constructor. + # This makes sure MCP tools are preserved when cloning agents for handoff workflows. + all_tools = list(options.tools) if options.tools else [] + if agent._local_mcp_tools: + all_tools.extend(agent._local_mcp_tools) + return ChatAgent( chat_client=agent.chat_client, instructions=options.instructions, @@ -101,7 +109,7 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent: store=options.store, temperature=options.temperature, tool_choice=options.tool_choice, # type: ignore[arg-type] - tools=list(options.tools) if options.tools else None, + tools=all_tools if all_tools else None, top_p=options.top_p, user=options.user, additional_chat_options=dict(options.additional_properties), diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 8042c68e08..44a6403c6f 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -3,6 +3,7 @@ from collections.abc import AsyncIterable, AsyncIterator from dataclasses import dataclass from typing import Any, cast +from unittest.mock import MagicMock import pytest @@ -10,6 +11,7 @@ from agent_framework import ( AgentRunResponse, AgentRunResponseUpdate, BaseAgent, + ChatAgent, ChatMessage, FunctionCallContent, HandoffBuilder, @@ -20,6 +22,8 @@ from agent_framework import ( WorkflowEvent, WorkflowOutputEvent, ) +from agent_framework._mcp import MCPTool +from agent_framework._workflows._handoff import _clone_chat_agent @dataclass @@ -368,3 +372,32 @@ async def test_handoff_async_termination_condition() -> None: user_messages = [msg for msg in final_conv_list if msg.role == Role.USER] assert len(user_messages) == 2 assert termination_call_count > 0 + + +async def test_clone_chat_agent_preserves_mcp_tools() -> None: + """Test that _clone_chat_agent preserves MCP tools when cloning an agent.""" + mock_chat_client = MagicMock() + + mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.name = "test_mcp_tool" + + def sample_function() -> str: + return "test" + + original_agent = ChatAgent( + chat_client=mock_chat_client, + name="TestAgent", + instructions="Test instructions", + tools=[mock_mcp_tool, sample_function], + ) + + assert hasattr(original_agent, "_local_mcp_tools") + assert len(original_agent._local_mcp_tools) == 1 + assert original_agent._local_mcp_tools[0] == mock_mcp_tool + + cloned_agent = _clone_chat_agent(original_agent) + + assert hasattr(cloned_agent, "_local_mcp_tools") + assert len(cloned_agent._local_mcp_tools) == 1 + assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool + assert len(cloned_agent.chat_options.tools) == 1 From 7b3e2a7e824753d907b555078e48932d9f3a61fc Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:20:13 +0900 Subject: [PATCH 03/16] Update changelog with a new PR that went in (#1912) --- python/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index c86947b527..3ccd587c09 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure agent thread is part of checkpoint ([#1756](https://github.com/microsoft/agent-framework/pull/1756)) - Fix middleware and cleanup confusing function ([#1865](https://github.com/microsoft/agent-framework/pull/1865)) - Fix type compatibility check ([#1753](https://github.com/microsoft/agent-framework/pull/1753)) +- Fix mcp tool cloning for handoff pattern ([#1883](https://github.com/microsoft/agent-framework/pull/1883)) ## [1.0.0b251028] - 2025-10-28 From f415959d33960e06de5278a743e4ca4198c872d7 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Wed, 5 Nov 2025 00:50:41 +0100 Subject: [PATCH 04/16] .NET: Add Writer-Critic Iterative Refinement Workflow Sample (#1790) * Adding Sample for writer-critic workflow implemented using Worfklow, custom executors, agents, switch, custom states, different entry points for the executors. * Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * using now structured output, with streaming for UX responsiveness. * improved comments and order, so comments directly precede what they're describing * fixing issue with internal class that the analyzer doesn't recognize that CriticDecision is instantiated, just indirectly via JSON deserialization --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 1 + .../GettingStarted/Workflows/README.md | 1 + .../08_WriterCriticWorkflow.csproj | 24 + .../08_WriterCriticWorkflow/Program.cs | 409 ++++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index de8aef42fc..86eee2fbbe 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -143,6 +143,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/README.md b/dotnet/samples/GettingStarted/Workflows/README.md index 4ea750e19e..072acfa560 100644 --- a/dotnet/samples/GettingStarted/Workflows/README.md +++ b/dotnet/samples/GettingStarted/Workflows/README.md @@ -19,6 +19,7 @@ Please begin with the [Foundational](./_Foundational) samples in order. These th | [Multi-Service Workflows](./_Foundational/05_MultiModelService) | Shows using multiple AI services in the same workflow | | [Sub-Workflows](./_Foundational/06_SubWorkflows) | Demonstrates composing workflows hierarchically by embedding workflows as executors | | [Mixed Workflow with Agents and Executors](./_Foundational/07_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling | +| [Writer-Critic Workflow](./_Foundational/08_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops | Once completed, please proceed to other samples listed below. diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj new file mode 100644 index 0000000000..3e8f2547d1 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + WriterCriticWorkflow + enable + enable + false + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs new file mode 100644 index 0000000000..fc39044b42 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace WriterCriticWorkflow; + +/// +/// This sample demonstrates an iterative refinement workflow between Writer and Critic agents. +/// +/// The workflow implements a content creation and review loop that: +/// 1. Writer creates initial content based on the user's request +/// 2. Critic reviews the content and provides feedback using structured output +/// 3. If approved: Summary executor presents the final content +/// 4. If rejected: Writer revises based on feedback (loops back) +/// 5. Continues until approval or max iterations (3) is reached +/// +/// This pattern is useful when you need: +/// - Iterative content improvement through feedback loops +/// - Quality gates with reviewer approval +/// - Maximum iteration limits to prevent infinite loops +/// - Conditional workflow routing based on agent decisions +/// - Structured output for reliable decision-making +/// +/// Key Learning: Workflows can implement loops with conditional edges, shared state, +/// and structured output for robust agent decision-making. +/// +/// +/// Pre-requisites: +/// - Previous foundational samples should be completed first. +/// - An Azure OpenAI chat completion deployment must be configured. +/// +public static class Program +{ + public const int MaxIterations = 3; + + private static async Task Main() + { + Console.WriteLine("\n=== Writer-Critic Iteration Workflow ===\n"); + Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n"); + + // Set up the Azure OpenAI client + string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + + // Create executors for content creation and review + WriterExecutor writer = new(chatClient); + CriticExecutor critic = new(chatClient); + SummaryExecutor summary = new(chatClient); + + // Build the workflow with conditional routing based on critic's decision + WorkflowBuilder workflowBuilder = new WorkflowBuilder(writer) + .AddEdge(writer, critic) + .AddSwitch(critic, sw => sw + .AddCase(cd => cd?.Approved == true, summary) + .AddCase(cd => cd?.Approved == false, writer)) + .WithOutputFrom(summary); + + // Execute the workflow with a sample task + // The workflow loops back to Writer if content is rejected, + // or proceeds to Summary if approved. State tracking ensures we don't loop forever. + Console.WriteLine(new string('=', 80)); + Console.WriteLine("TASK: Write a short blog post about AI ethics (200 words)"); + Console.WriteLine(new string('=', 80) + "\n"); + + const string InitialTask = "Write a 200-word blog post about AI ethics. Make it thoughtful and engaging."; + + Workflow workflow = workflowBuilder.Build(); + await ExecuteWorkflowAsync(workflow, InitialTask); + + Console.WriteLine("\n✅ Sample Complete: Writer-Critic iteration demonstrates conditional workflow loops\n"); + Console.WriteLine("Key Concepts Demonstrated:"); + Console.WriteLine(" ✓ Iterative refinement loop with conditional routing"); + Console.WriteLine(" ✓ Shared workflow state for iteration tracking"); + Console.WriteLine($" ✓ Max iteration cap ({MaxIterations}) for safety"); + Console.WriteLine(" ✓ Multiple message handlers in a single executor"); + Console.WriteLine(" ✓ Streaming support with structured output\n"); + } + + private static async Task ExecuteWorkflowAsync(Workflow workflow, string input) + { + // Execute in streaming mode to see real-time progress + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + + // Watch the workflow events + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + case AgentRunUpdateEvent agentUpdate: + // Stream agent output in real-time + if (!string.IsNullOrEmpty(agentUpdate.Update.Text)) + { + Console.Write(agentUpdate.Update.Text); + } + break; + + case WorkflowOutputEvent output: + Console.WriteLine("\n\n" + new string('=', 80)); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("✅ FINAL APPROVED CONTENT"); + Console.ResetColor(); + Console.WriteLine(new string('=', 80)); + Console.WriteLine(); + Console.WriteLine(output.Data); + Console.WriteLine(); + Console.WriteLine(new string('=', 80)); + break; + } + } + } +} + +// ==================================== +// Shared State for Iteration Tracking +// ==================================== + +/// +/// Tracks the current iteration and conversation history across workflow executions. +/// +internal sealed class FlowState +{ + public int Iteration { get; set; } = 1; + public List History { get; } = []; +} + +/// +/// Constants for accessing the shared flow state in workflow context. +/// +internal static class FlowStateShared +{ + public const string Scope = "FlowStateScope"; + public const string Key = "singleton"; +} + +/// +/// Helper methods for reading and writing shared flow state. +/// +internal static class FlowStateHelpers +{ + public static async Task ReadFlowStateAsync(IWorkflowContext context) + { + FlowState? state = await context.ReadStateAsync(FlowStateShared.Key, scopeName: FlowStateShared.Scope); + return state ?? new FlowState(); + } + + public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState state) + => context.QueueStateUpdateAsync(FlowStateShared.Key, state, scopeName: FlowStateShared.Scope); +} + +// ==================================== +// Data Transfer Objects +// ==================================== + +/// +/// Structured output schema for the Critic's decision. +/// Uses JsonPropertyName and Description attributes for OpenAI's JSON schema. +/// +[Description("Critic's review decision including approval status and feedback")] +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via JSON deserialization")] +internal sealed class CriticDecision +{ + [JsonPropertyName("approved")] + [Description("Whether the content is approved (true) or needs revision (false)")] + public bool Approved { get; set; } + + [JsonPropertyName("feedback")] + [Description("Specific feedback for improvements if not approved, empty if approved")] + public string Feedback { get; set; } = ""; + + // Non-JSON properties for workflow use + [JsonIgnore] + public string Content { get; set; } = ""; + + [JsonIgnore] + public int Iteration { get; set; } +} + +// ==================================== +// Custom Executors +// ==================================== + +/// +/// Executor that creates or revises content based on user requests or critic feedback. +/// This executor demonstrates multiple message handlers for different input types. +/// +internal sealed class WriterExecutor : Executor +{ + private readonly AIAgent _agent; + + public WriterExecutor(IChatClient chatClient) : base("Writer") + { + this._agent = new ChatClientAgent( + chatClient, + name: "Writer", + instructions: """ + You are a skilled writer. Create clear, engaging content. + If you receive feedback, carefully revise the content to address all concerns. + Maintain the same topic and length requirements. + """ + ); + } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder + .AddHandler(this.HandleInitialRequestAsync) + .AddHandler(this.HandleRevisionRequestAsync); + + /// + /// Handles the initial writing request from the user. + /// + private async ValueTask HandleInitialRequestAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken); + } + + /// + /// Handles revision requests from the critic with feedback. + /// + private async ValueTask HandleRevisionRequestAsync( + CriticDecision decision, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + string prompt = "Revise the following content based on this feedback:\n\n" + + $"Feedback: {decision.Feedback}\n\n" + + $"Original Content:\n{decision.Content}"; + + return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, prompt), context, cancellationToken); + } + + /// + /// Core implementation for generating content (initial or revised). + /// + private async Task HandleAsyncCoreAsync( + ChatMessage message, + IWorkflowContext context, + CancellationToken cancellationToken) + { + FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context); + + Console.WriteLine($"\n=== Writer (Iteration {state.Iteration}) ===\n"); + + StringBuilder sb = new(); + await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + sb.Append(update.Text); + Console.Write(update.Text); + } + } + Console.WriteLine("\n"); + + string text = sb.ToString(); + state.History.Add(new ChatMessage(ChatRole.Assistant, text)); + await FlowStateHelpers.SaveFlowStateAsync(context, state); + + return new ChatMessage(ChatRole.User, text); + } +} + +/// +/// Executor that reviews content and decides whether to approve or request revisions. +/// Uses structured output with streaming for reliable decision-making. +/// +internal sealed class CriticExecutor : Executor +{ + private readonly AIAgent _agent; + + public CriticExecutor(IChatClient chatClient) : base("Critic") + { + this._agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions + { + Name = "Critic", + Instructions = """ + You are a constructive critic. Review the content and provide specific feedback. + Always try to provide actionable suggestions for improvement and strive to identify improvement points. + Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points. + + Provide your decision as structured output with: + - approved: true if content is good, false if revisions needed + - feedback: specific improvements needed (empty if approved) + + Be concise but specific in your feedback. + """, + ChatOptions = new() + { + ResponseFormat = ChatResponseFormat.ForJsonSchema() + } + }); + } + + public override async ValueTask HandleAsync( + ChatMessage message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context); + + Console.WriteLine($"=== Critic (Iteration {state.Iteration}) ===\n"); + + // Use RunStreamingAsync to get streaming updates, then deserialize at the end + IAsyncEnumerable updates = this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken); + + // Stream the output in real-time (for any rationale/explanation) + await foreach (AgentRunResponseUpdate update in updates) + { + if (!string.IsNullOrEmpty(update.Text)) + { + Console.Write(update.Text); + } + } + Console.WriteLine("\n"); + + // Convert the stream to a response and deserialize the structured output + AgentRunResponse response = await updates.ToAgentRunResponseAsync(cancellationToken); + CriticDecision decision = response.Deserialize(JsonSerializerOptions.Web); + + Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}"); + if (!string.IsNullOrEmpty(decision.Feedback)) + { + Console.WriteLine($"Feedback: {decision.Feedback}"); + } + Console.WriteLine(); + + // Safety: approve if max iterations reached + if (!decision.Approved && state.Iteration >= Program.MaxIterations) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"⚠️ Max iterations ({Program.MaxIterations}) reached - auto-approving"); + Console.ResetColor(); + decision.Approved = true; + decision.Feedback = ""; + } + + // Increment iteration ONLY if rejecting (will loop back to Writer) + if (!decision.Approved) + { + state.Iteration++; + } + + // Store the decision in history + state.History.Add(new ChatMessage(ChatRole.Assistant, + $"[Decision: {(decision.Approved ? "Approved" : "Needs Revision")}] {decision.Feedback}")); + await FlowStateHelpers.SaveFlowStateAsync(context, state); + + // Populate workflow-specific fields + decision.Content = message.Text ?? ""; + decision.Iteration = state.Iteration; + + return decision; + } +} + +/// +/// Executor that presents the final approved content to the user. +/// +internal sealed class SummaryExecutor : Executor +{ + private readonly AIAgent _agent; + + public SummaryExecutor(IChatClient chatClient) : base("Summary") + { + this._agent = new ChatClientAgent( + chatClient, + name: "Summary", + instructions: """ + You present the final approved content to the user. + Simply output the polished content - no additional commentary needed. + """ + ); + } + + public override async ValueTask HandleAsync( + CriticDecision message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine("=== Summary ===\n"); + + string prompt = $"Present this approved content:\n\n{message.Content}"; + + StringBuilder sb = new(); + await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + sb.Append(update.Text); + } + } + + ChatMessage result = new(ChatRole.Assistant, sb.ToString()); + await context.YieldOutputAsync(result, cancellationToken); + return result; + } +} From 2499262f300202264ef5290f977e78dfba2cede1 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:30:56 +0900 Subject: [PATCH 05/16] Add orchestration samples link (#1914) --- python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/README.md b/python/README.md index 235385a48a..06eca19999 100644 --- a/python/README.md +++ b/python/README.md @@ -233,7 +233,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -**Note**: Advanced orchestration patterns like GroupChat, Sequential, and Concurrent orchestrations are coming soon. +For more advanced orchestration patterns including Sequential, GroupChat, Concurrent, Magentic, and Handoff orchestrations, see the [orchestration samples](samples/getting_started/workflows/orchestration). ## More Examples & Samples From 552f7c781da37d59dc6fa37ed496f9b20be8f5a5 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Tue, 4 Nov 2025 16:37:31 -0800 Subject: [PATCH 06/16] .NET: Make sure Workflow activities are as expected (#1903) * Make sure Workflow activities are as expected * misc * Copliot comments * Fix unit tests * Improve test stability * Fix unit tests * Fix formatting --- .../Execution/StreamingRunEventStream.cs | 22 ++- .../ObservabilityTests.cs | 186 ++++++++++++++++++ 2 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index e6afbb5440..ca0cc52641 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -2,10 +2,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Observability; namespace Microsoft.Agents.AI.Workflows.Execution; @@ -15,6 +17,9 @@ namespace Microsoft.Agents.AI.Workflows.Execution; /// internal sealed class StreamingRunEventStream : IRunEventStream { + private static readonly string s_namespace = typeof(StreamingRunEventStream).Namespace!; + private static readonly ActivitySource s_activitySource = new(s_namespace); + private readonly Channel _eventChannel; private readonly ISuperStepRunner _stepRunner; private readonly InputWaiter _inputWaiter; @@ -58,6 +63,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Subscribe to events - they will flow directly to the channel as they're raised this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync; + using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun); + activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId); + try { // Wait for the first input before starting @@ -65,6 +73,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false); this._runStatus = RunStatus.Running; + activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); while (!linkedSource.Token.IsCancellationRequested) { @@ -99,9 +108,17 @@ internal sealed class StreamingRunEventStream : IRunEventStream { // Expected during shutdown } - catch (Exception e) + catch (Exception ex) { - await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(e), linkedSource.Token).ConfigureAwait(false); + if (activity != null) + { + activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() { + { Tags.ErrorType, ex.GetType().FullName }, + { Tags.BuildErrorMessage, ex.Message }, + })); + activity.CaptureException(ex); + } + await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false); } finally { @@ -110,6 +127,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Mark as ended when run loop exits this._runStatus = RunStatus.Ended; + activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted)); } async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs new file mode 100644 index 0000000000..7101ad13d4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Agents.AI.Workflows.Observability; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// These tests ensure that OpenTelemetry Activity traces are properly created for workflow monitoring. +/// Tests are run in a collection to avoid parallel execution since ActivityListener is global. +/// Each test creates a new instance of ObservabilityTests and runs in serial within the collection. +/// This prevents interference between tests due to the global nature of ActivityListener. +/// +[Collection("ObservabilityTests")] +public sealed class ObservabilityTests : IDisposable +{ + private readonly ActivityListener _activityListener; + private readonly ConcurrentBag _capturedActivities = []; + + private bool _isDisposed; + + public ObservabilityTests() + { + // Set up activity listener to capture activities from workflow + // This is global and captures ALL workflow activities from ANY test in the same process! + this._activityListener = new ActivityListener + { + ShouldListenTo = source => source.Name.Contains(typeof(Workflow).Namespace!), + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + ActivityStarted = activity => this._capturedActivities.Add(activity), + }; + ActivitySource.AddActivityListener(this._activityListener); + } + + /// + /// Create a sample workflow for testing. + /// + /// + /// This workflow is expected to create 8 activities that will be captured by the tests + /// - ActivityNames.WorkflowBuild + /// - ActivityNames.WorkflowRun + /// -- ActivityNames.EdgeGroupProcess + /// -- ActivityNames.ExecutorProcess (UppercaseExecutor) + /// --- ActivityNames.MessageSend + /// ---- ActivityNames.EdgeGroupProcess + /// -- ActivityNames.ExecutorProcess (ReverseTextExecutor) + /// --- ActivityNames.MessageSend + /// + /// The created workflow. + private static Workflow CreateWorkflow() + { + // Create the executors + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + Func reverseFunc = s => new string(s.Reverse().ToArray()); + var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor"); + + // Build the workflow by connecting executors sequentially + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + + return builder.Build(); + } + + private static Dictionary GetExpectedActivityNameCounts() => + new() + { + { ActivityNames.WorkflowBuild, 1 }, + { ActivityNames.WorkflowRun, 1 }, + { ActivityNames.EdgeGroupProcess, 2 }, + { ActivityNames.ExecutorProcess, 2 }, + { ActivityNames.MessageSend, 2 } + }; + + private static InProcessExecutionEnvironment GetExecutionEnvironment(string name) => + name switch + { + "Default" => InProcessExecution.Default, + "Lockstep" => InProcessExecution.Lockstep, + "OffThread" => InProcessExecution.OffThread, + "Concurrent" => InProcessExecution.Concurrent, + _ => throw new ArgumentException($"Unknown execution environment name: {name}") + }; + + public void Dispose() + { + if (!this._isDisposed) + { + this._activityListener?.Dispose(); + this._isDisposed = true; + } + } + + private async Task TestWorkflowEndToEndActivitiesAsync(string executionEnvironmentName) + { + // Arrange + // Create a test activity to correlate captured activities + using var testActivity = new Activity("ObservabilityTest").Start(); + + // Act + var workflow = CreateWorkflow(); + var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName); + Run run = await executionEnvironment.RunAsync(workflow, "Hello, World!"); + await run.DisposeAsync(); + + await Task.Delay(100); // Allow time for activities to be captured + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().HaveCount(8, "Exactly 8 activities should be created."); + + // Make sure all expected activities exist and have the correct count + foreach (var kvp in GetExpectedActivityNameCounts()) + { + var activityName = kvp.Key; + var expectedCount = kvp.Value; + var actualCount = capturedActivities.Count(a => a.OperationName == activityName); + actualCount.Should().Be(expectedCount, $"Activity '{activityName}' should occur {expectedCount} times."); + } + + // Verify WorkflowRun activity events include workflow lifecycle events + var workflowRunActivity = capturedActivities.First(a => a.OperationName == ActivityNames.WorkflowRun); + var activityEvents = workflowRunActivity.Events.ToList(); + activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowStarted, "activity should have workflow started event"); + activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event"); + } + + [Fact] + public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync() + { + await this.TestWorkflowEndToEndActivitiesAsync("Default"); + } + + [Fact] + public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync() + { + await this.TestWorkflowEndToEndActivitiesAsync("OffThread"); + } + + [Fact] + public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync() + { + await this.TestWorkflowEndToEndActivitiesAsync("Concurrent"); + } + + [Fact] + public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync() + { + await this.TestWorkflowEndToEndActivitiesAsync("Lockstep"); + } + + [Fact] + public async Task CreatesWorkflowActivities_WithCorrectNameAsync() + { + // Arrange + // Create a test activity to correlate captured activities + using var testActivity = new Activity("ObservabilityTest").Start(); + + // Act + CreateWorkflow(); + await Task.Delay(100); // Allow time for activities to be captured + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + capturedActivities.Should().HaveCount(1, "Exactly 1 activity should be created."); + capturedActivities[0].OperationName.Should().Be(ActivityNames.WorkflowBuild, + "The activity should have the correct operation name for workflow build."); + + var events = capturedActivities[0].Events.ToList(); + events.Should().Contain(e => e.Name == EventNames.BuildStarted, "activity should have build started event"); + events.Should().Contain(e => e.Name == EventNames.BuildValidationCompleted, "activity should have build validation completed event"); + events.Should().Contain(e => e.Name == EventNames.BuildCompleted, "activity should have build completed event"); + + var tags = capturedActivities[0].Tags.ToDictionary(t => t.Key, t => t.Value); + tags.Should().ContainKey(Tags.WorkflowId); + tags.Should().ContainKey(Tags.WorkflowDefinition); + } +} From bbde248839b5ac5534a36792891170b4e5f060a0 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:40:10 -0800 Subject: [PATCH 07/16] .NET: Add unit tests for CreateConversation executor (#1915) * Add unit tests for create conversation executor * Update indentation and comment typo. --- .../MockAgentProvider.cs | 31 ++++++++ .../CreateConversationExecutorTest.cs | 75 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CreateConversationExecutorTest.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs new file mode 100644 index 0000000000..67e4c68c5e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +/// +/// Mock implementation of for unit testing purposes. +/// +internal sealed class MockAgentProvider : Mock +{ + public IList ExistingConversationIds { get; } = []; + + public MockAgentProvider() + { + this.Setup(provider => provider.CreateConversationAsync(It.IsAny())) + .Returns(() => Task.FromResult(this.CreateConversationId())); + } + + private string CreateConversationId() + { + string newConversationId = Guid.NewGuid().ToString("N"); + this.ExistingConversationIds.Add(newConversationId); + + return newConversationId; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CreateConversationExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CreateConversationExecutorTest.cs new file mode 100644 index 0000000000..5636b3e65f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CreateConversationExecutorTest.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class CreateConversationExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task CreateNewConversationAsync() + { + // Arrange, Act, Assert + await this.ExecuteTestAsync(nameof(CreateNewConversationAsync), + "TestConversationId", + executionIteration: 1); + } + + [Fact] + public async Task CreateMultipleConversationsAsync() + { + // Arrange, Act, Assert + await this.ExecuteTestAsync(nameof(CreateMultipleConversationsAsync), + "TestConversationId", + executionIteration: 4); + } + + private async Task ExecuteTestAsync( + string displayName, + string variableName, + int executionIteration) + { + // Arrange + // Initialize state to simulate workflow environment. + this.State.InitializeSystem(); + CreateConversation model = this.CreateModel( + this.FormatDisplayName(displayName), + FormatVariablePath(variableName)); + MockAgentProvider mockAgentProvider = new(); + CreateConversationExecutor action = new(model, mockAgentProvider.Object, this.State); + + // Act + int expectedIterationCount = executionIteration; + while (executionIteration-- > 0) + { + await this.ExecuteAsync(action); + } + + // Assert + VerifyModel(model, action); + Assert.Equal(expected: expectedIterationCount, actual: mockAgentProvider.ExistingConversationIds.Count); + this.VerifyState("TestConversationId", FormulaValue.New(mockAgentProvider.ExistingConversationIds.Last())); + } + + private CreateConversation CreateModel(string displayName, string conversationIdVariable) + { + CreateConversation.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + ConversationId = PropertyPath.Create(conversationIdVariable) + }; + + return AssignParent(actionBuilder); + } +} From 0c862e97a6ff6c9b2053aedd9e3741f216fb0d9f Mon Sep 17 00:00:00 2001 From: Eric Zhu Date: Tue, 4 Nov 2025 18:11:40 -0800 Subject: [PATCH 08/16] Python: feat: Add ChatKit integration with a sample application (#1273) * feat: Add ChatKit integration with a new frontend application - Created a new frontend application using React and Vite for the ChatKit integration. - Added essential files including package.json, vite.config.ts, and Tailwind CSS configuration. - Implemented core components: App, Home, ChatKitPanel, ThemeToggle, and hooks for color scheme management. - Established SQLite-based store implementation for ChatKit data persistence in store.py. - Integrated theme toggling functionality for light and dark modes. - Set up ESLint and TypeScript configurations for better development experience. * git ignore * fix mypy * add mising file * minimal frontend for chatkit sample * update ignore files * version * set python version lowerbound on chatkit * update project settings for chatkit * update setup * update setup * update setup * update setup * weather widget * add select city widget sample * remove widget helper * update chatkit to include file attachments and cover more thread item types * update readme with mermaid diagram * update diagram * update instructions * update chatkit dependency * fix converter imports * move to demos/ * move to demos/ -- rename references * support multiple session instead of using global variable in sample * support chunk streaming * fix tests * Update python/samples/demos/chatkit-integration/store.py Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> * use local host --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .gitignore | 10 +- python/.pre-commit-config.yaml | 2 +- python/packages/chatkit/.gitignore | 3 + python/packages/chatkit/LICENSE | 21 + python/packages/chatkit/README.md | 87 + .../agent_framework_chatkit/__init__.py | 25 + .../agent_framework_chatkit/_converter.py | 603 +++++++ .../agent_framework_chatkit/_streaming.py | 104 ++ .../chatkit/agent_framework_chatkit/py.typed | 0 python/packages/chatkit/pyproject.toml | 89 + python/packages/chatkit/tests/__init__.py | 1 + .../packages/chatkit/tests/test_converter.py | 426 +++++ .../packages/chatkit/tests/test_streaming.py | 142 ++ .../core/agent_framework/chatkit/__init__.py | 23 + .../core/agent_framework/chatkit/__init__.pyi | 10 + python/pyproject.toml | 3 + .../demos/chatkit-integration/.gitignore | 4 + .../demos/chatkit-integration/README.md | 268 +++ .../demos/chatkit-integration/__init__.py | 1 + .../samples/demos/chatkit-integration/app.py | 538 ++++++ .../chatkit-integration/attachment_store.py | 121 ++ .../chatkit-integration/frontend/index.html | 52 + .../frontend/package-lock.json | 1437 +++++++++++++++++ .../chatkit-integration/frontend/package.json | 27 + .../chatkit-integration/frontend/src/App.tsx | 33 + .../chatkit-integration/frontend/src/main.tsx | 15 + .../frontend/src/vite-env.d.ts | 1 + .../frontend/tsconfig.json | 21 + .../frontend/tsconfig.node.json | 10 + .../frontend/vite.config.ts | 24 + .../demos/chatkit-integration/store.py | 361 +++++ .../chatkit-integration/weather_widget.py | 437 +++++ python/uv.lock | 75 + 33 files changed, 4972 insertions(+), 2 deletions(-) create mode 100644 python/packages/chatkit/.gitignore create mode 100644 python/packages/chatkit/LICENSE create mode 100644 python/packages/chatkit/README.md create mode 100644 python/packages/chatkit/agent_framework_chatkit/__init__.py create mode 100644 python/packages/chatkit/agent_framework_chatkit/_converter.py create mode 100644 python/packages/chatkit/agent_framework_chatkit/_streaming.py create mode 100644 python/packages/chatkit/agent_framework_chatkit/py.typed create mode 100644 python/packages/chatkit/pyproject.toml create mode 100644 python/packages/chatkit/tests/__init__.py create mode 100644 python/packages/chatkit/tests/test_converter.py create mode 100644 python/packages/chatkit/tests/test_streaming.py create mode 100644 python/packages/core/agent_framework/chatkit/__init__.py create mode 100644 python/packages/core/agent_framework/chatkit/__init__.pyi create mode 100644 python/samples/demos/chatkit-integration/.gitignore create mode 100644 python/samples/demos/chatkit-integration/README.md create mode 100644 python/samples/demos/chatkit-integration/__init__.py create mode 100644 python/samples/demos/chatkit-integration/app.py create mode 100644 python/samples/demos/chatkit-integration/attachment_store.py create mode 100644 python/samples/demos/chatkit-integration/frontend/index.html create mode 100644 python/samples/demos/chatkit-integration/frontend/package-lock.json create mode 100644 python/samples/demos/chatkit-integration/frontend/package.json create mode 100644 python/samples/demos/chatkit-integration/frontend/src/App.tsx create mode 100644 python/samples/demos/chatkit-integration/frontend/src/main.tsx create mode 100644 python/samples/demos/chatkit-integration/frontend/src/vite-env.d.ts create mode 100644 python/samples/demos/chatkit-integration/frontend/tsconfig.json create mode 100644 python/samples/demos/chatkit-integration/frontend/tsconfig.node.json create mode 100644 python/samples/demos/chatkit-integration/frontend/vite.config.ts create mode 100644 python/samples/demos/chatkit-integration/store.py create mode 100644 python/samples/demos/chatkit-integration/weather_widget.py diff --git a/.gitignore b/.gitignore index e50f681ec6..70c1563f5a 100644 --- a/.gitignore +++ b/.gitignore @@ -203,4 +203,12 @@ agents.md # AI .claude/ -WARP.md \ No newline at end of file +WARP.md + +# Frontend +**/frontend/node_modules/ +**/frontend/.vite/ +**/frontend/dist/ + +# Database files +*.db \ No newline at end of file diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml index b00f87602d..a6274114af 100644 --- a/python/.pre-commit-config.yaml +++ b/python/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: check-json name: Check JSON files files: \.json$ - exclude: ^.*\.vscode\/.* + exclude: ^.*\.vscode\/.*|^python/demos/samples/chatkit-integration/frontend/(tsconfig.*\.json|package-lock\.json)$ - id: end-of-file-fixer name: Fix End of File files: \.py$ diff --git a/python/packages/chatkit/.gitignore b/python/packages/chatkit/.gitignore new file mode 100644 index 0000000000..ae3d2207de --- /dev/null +++ b/python/packages/chatkit/.gitignore @@ -0,0 +1,3 @@ +chatkit-python +openai-chatkit-advanced-samples +chatkit-js \ No newline at end of file diff --git a/python/packages/chatkit/LICENSE b/python/packages/chatkit/LICENSE new file mode 100644 index 0000000000..ce29e72a36 --- /dev/null +++ b/python/packages/chatkit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md new file mode 100644 index 0000000000..237cf94227 --- /dev/null +++ b/python/packages/chatkit/README.md @@ -0,0 +1,87 @@ +# Agent Framework and ChatKit Integration + +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: + +- `stream_agent_response`: A helper to convert a streamed `AgentRunResponseUpdate` + from a Microsoft Agent Framework agent that implements `AgentProtocol` to ChatKit events. +- `ThreadItemConverter`: A extendable helper class to convert ChatKit thread items to + `ChatMessage` objects that can be consumed by an Agent Framework agent. +- `simple_to_agent_input`: A helper function that uses the default implementation + of `ThreadItemConverter` to convert a ChatKit thread to a list of `ChatMessage`, + useful for getting started quickly. + +## Installation + +```bash +pip install agent-framework-chatkit --pre +``` + +This will install `agent-framework-core` and `openai-chatkit` as dependencies. + +## Example Usage + +Here's a minimal example showing how to integrate Agent Framework with ChatKit: + +```python +from collections.abc import AsyncIterator +from typing import Any + +from azure.identity import AzureCliCredential +from fastapi import FastAPI, Request +from fastapi.responses import Response, StreamingResponse + +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.chatkit import simple_to_agent_input, stream_agent_response + +from chatkit.server import ChatKitServer +from chatkit.types import ThreadMetadata, UserMessageItem, ThreadStreamEvent + +# You'll need to implement a Store - see the sample for a SQLiteStore implementation +from your_store import YourStore # type: ignore[import-not-found] # Replace with your Store implementation + +# Define your agent with tools +agent = ChatAgent( + chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful assistant.", + tools=[], # Add your tools here +) + +# Create a ChatKit server that uses your agent +class MyChatKitServer(ChatKitServer[dict[str, Any]]): + async def respond( + self, + thread: ThreadMetadata, + input_user_message: UserMessageItem | None, + context: dict[str, Any], + ) -> AsyncIterator[ThreadStreamEvent]: + if input_user_message is None: + return + + # Convert ChatKit message to Agent Framework format + agent_messages = await simple_to_agent_input(input_user_message) + + # Run the agent and stream responses + response_stream = agent.run_stream(agent_messages) + + # Convert agent responses back to ChatKit events + async for event in stream_agent_response(response_stream, thread.id): + yield event + +# Set up FastAPI endpoint +app = FastAPI() +chatkit_server = MyChatKitServer(YourStore()) # type: ignore[misc] + +@app.post("/chatkit") +async def chatkit_endpoint(request: Request): + result = await chatkit_server.process(await request.body(), {"request": request}) + + if hasattr(result, '__aiter__'): # Streaming + return StreamingResponse(result, media_type="text/event-stream") # type: ignore[arg-type] + else: # Non-streaming + return Response(content=result.json, media_type="application/json") # type: ignore[union-attr] +``` + +For a complete end-to-end example with a full frontend, see the [weather agent sample](../../samples/demos/chatkit-integration/README.md). diff --git a/python/packages/chatkit/agent_framework_chatkit/__init__.py b/python/packages/chatkit/agent_framework_chatkit/__init__.py new file mode 100644 index 0000000000..8c01a6dfad --- /dev/null +++ b/python/packages/chatkit/agent_framework_chatkit/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent Framework and ChatKit Integration. + +This package provides an integration layer between Microsoft Agent Framework +and OpenAI ChatKit (Python). It mirrors the Agent SDK integration and provides +helpers to convert between Agent Framework and ChatKit types. +""" + +import importlib.metadata + +from ._converter import ThreadItemConverter, simple_to_agent_input +from ._streaming import stream_agent_response + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "ThreadItemConverter", + "__version__", + "simple_to_agent_input", + "stream_agent_response", +] diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py new file mode 100644 index 0000000000..4c911f5604 --- /dev/null +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -0,0 +1,603 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Converter utilities for converting ChatKit thread items to Agent Framework messages.""" + +import logging +import sys +from collections.abc import Awaitable, Callable, Sequence + +if sys.version_info >= (3, 11): + from typing import assert_never +else: + from typing_extensions import assert_never + +from agent_framework import ( + ChatMessage, + DataContent, + FunctionCallContent, + FunctionResultContent, + Role, + TextContent, + UriContent, +) +from chatkit.types import ( + AssistantMessageItem, + Attachment, + ClientToolCallItem, + EndOfTurnItem, + HiddenContextItem, + ImageAttachment, + TaskItem, + ThreadItem, + UserMessageItem, + UserMessageTagContent, + UserMessageTextContent, + WidgetItem, + WorkflowItem, +) + +logger = logging.getLogger(__name__) + + +class ThreadItemConverter: + """Helper class to convert ChatKit thread items to Agent Framework ChatMessage objects. + + This class provides a base implementation for converting ChatKit thread items + to Agent Framework messages. It can be extended to handle attachments, + @-mentions, hidden context items, and custom thread item formats. + + Args: + attachment_data_fetcher: Optional async function to fetch attachment binary data. + If provided, it should take an attachment ID and return the binary data as bytes. + If not provided, attachments will be converted to UriContent using available URLs. + """ + + def __init__( + self, + attachment_data_fetcher: Callable[[str], Awaitable[bytes]] | None = None, + ) -> None: + """Initialize the converter. + + Args: + attachment_data_fetcher: Optional async function to fetch attachment data by ID. + """ + self.attachment_data_fetcher = attachment_data_fetcher + + async def user_message_to_input( + self, item: UserMessageItem, is_last_message: bool = True + ) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit UserMessageItem to Agent Framework ChatMessage(s). + + This method is called internally by `to_agent_input()`. Override this method + to customize how user messages are converted. + + Args: + item: The ChatKit user message item to convert. + is_last_message: Whether this is the last message in the thread (used for quoted_text handling). + + Returns: + A ChatMessage, list of messages, or None to skip. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + """ + # Extract text content from the user message + text_content = "" + if item.content: + for content_part in item.content: + if isinstance(content_part, UserMessageTextContent): + text_content += content_part.text + + # Convert attachments to DataContent or UriContent + data_contents: list[DataContent | UriContent] = [] + 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) + + # Create the message with text and attachments + if not text_content.strip() and not data_contents: + return None + + # If only text and no attachments, use text parameter for simplicity + if text_content.strip() and not data_contents: + user_message = ChatMessage(role=Role.USER, text=text_content.strip()) + else: + # Build contents list with both text and attachments + contents: list[TextContent | DataContent | UriContent] = [] + if text_content.strip(): + contents.append(TextContent(text=text_content.strip())) + contents.extend(data_contents) + user_message = ChatMessage(role=Role.USER, contents=contents) + + # Handle quoted text if this is the last message + messages = [user_message] + if item.quoted_text and is_last_message: + quoted_context = ChatMessage( + role=Role.USER, + text=f"The user is referring to this in particular:\n{item.quoted_text}", + ) + # Prepend quoted context before the main message + messages.insert(0, quoted_context) + + return messages + + async def attachment_to_message_content(self, attachment: Attachment) -> DataContent | UriContent | None: + """Convert a ChatKit attachment to Agent Framework content. + + This method is called internally by `user_message_to_input()` to handle attachments. + Override this method to customize attachment handling for your storage backend. + + The default implementation provides two strategies: + 1. If an attachment_data_fetcher was provided, it fetches the binary data + and creates a DataContent object + 2. Otherwise, for ImageAttachment with preview_url, it creates a UriContent object + + For FileAttachment without a data fetcher, returns None (attachment is skipped). + + Args: + attachment: The ChatKit attachment to convert (FileAttachment or ImageAttachment). + + Returns: + DataContent if binary data is available, UriContent if only URL is available, + or None if the attachment cannot be converted. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types including attachments within user messages. + + Examples: + .. code-block:: python + + # With data fetcher + async def fetch_data(attachment_id: str) -> bytes: + return await my_storage.get_file(attachment_id) + + + converter = ThreadItemConverter(attachment_data_fetcher=fetch_data) + messages = await converter.to_agent_input(thread_items) + + # Without data fetcher (uses URLs for images) + converter = ThreadItemConverter() + messages = await converter.to_agent_input(thread_items) + """ + # If we have a data fetcher, use it to get binary data + if self.attachment_data_fetcher is not None: + try: + data = await self.attachment_data_fetcher(attachment.id) + return DataContent(data=data, media_type=attachment.mime_type) + except Exception as e: + # If fetch fails, fall through to URL-based approach + logger.debug(f"Failed to fetch attachment data for {attachment.id}: {e}") + + # For ImageAttachment, try to use preview_url + if isinstance(attachment, ImageAttachment) and attachment.preview_url: + return UriContent(uri=str(attachment.preview_url), media_type=attachment.mime_type) + + # For FileAttachment without data fetcher, skip the attachment + # Subclasses can override this method to provide custom handling + return None + + def hidden_context_to_input(self, item: HiddenContextItem) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit HiddenContextItem to Agent Framework ChatMessage(s). + + This method is called internally by `to_agent_input()`. Override this method + to customize how hidden context is converted. + + The default implementation wraps the hidden context in XML tags and returns + a system message. This allows the model to distinguish hidden context from + regular conversation. + + Args: + item: The ChatKit hidden context item to convert. + + Returns: + A ChatMessage with system role, a list of messages, or None to skip. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + + Examples: + .. code-block:: python + + # Default behavior + converter = ThreadItemConverter() + hidden_item = HiddenContextItem( + id="ctx_1", + thread_id="thread_1", + created_at=datetime.now(), + content="User's email: user@example.com", + ) + message = converter.hidden_context_to_input(hidden_item) + # Returns: ChatMessage(role=SYSTEM, text="User's email: ...") + """ + return ChatMessage(role=Role.SYSTEM, text=f"{item.content}") + + def tag_to_message_content(self, tag: UserMessageTagContent) -> TextContent: + """Convert a ChatKit tag (@-mention) to Agent Framework content. + + This method is called internally by `user_message_to_input()` to handle tags. + Override this method to customize tag conversion for your application. + + The default implementation extracts the tag's display name and wraps it in + XML tags to provide context to the model about the @-mention. + + Args: + tag: The ChatKit tag content to convert. + + Returns: + TextContent with the tag information. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types including tags within user messages. + + Examples: + .. code-block:: python + + # Default behavior + converter = ThreadItemConverter() + tag = UserMessageTagContent( + type="input_tag", id="tag_1", text="john", data={"name": "John Doe"}, interactive=False + ) + content = converter.tag_to_message_content(tag) + # Returns: TextContent(text="Name:John Doe") + """ + name = getattr(tag.data, "name", tag.text if hasattr(tag, "text") else "unknown") + return TextContent(text=f"Name:{name}") + + def task_to_input(self, item: TaskItem) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit TaskItem to Agent Framework ChatMessage(s). + + This method is called internally by `to_agent_input()`. Override this method + to customize how tasks are converted. + + The default implementation converts custom tasks with title/content into + a user message explaining what task was displayed to the user. + + Args: + item: The ChatKit task item to convert. + + Returns: + A ChatMessage, a list of messages, or None to skip the task. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + + Examples: + .. code-block:: python + + # Task with both title and content + from chatkit.types import Task + + task_item = TaskItem( + id="task_1", + thread_id="thread_1", + created_at=datetime.now(), + task=Task(type="custom", title="Data Analysis", content="Analyzed sales data"), + ) + message = converter.task_to_input(task_item) + # Returns message explaining the task was performed + """ + if item.task.type != "custom" or (not item.task.title and not item.task.content): + return None + + title = item.task.title or "" + content = item.task.content or "" + task_text = f"{title}: {content}" if title and content else title or content + text = ( + f"A message was displayed to the user that the following task was performed:\n\n{task_text}\n" + ) + + return ChatMessage(role=Role.USER, text=text) + + def workflow_to_input(self, item: WorkflowItem) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit WorkflowItem to Agent Framework ChatMessage(s). + + This method is called internally by `to_agent_input()`. Override this method + to customize how workflows are converted. + + The default implementation converts each custom task in the workflow into + a separate user message explaining what tasks were performed. + + Args: + item: The ChatKit workflow item to convert. + + Returns: + A list of ChatMessages (one per task), a single message, or None to skip. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + + Examples: + .. code-block:: python + + # Workflow with multiple tasks + from chatkit.types import Workflow, Task + + workflow_item = WorkflowItem( + id="wf_1", + thread_id="thread_1", + created_at=datetime.now(), + workflow=Workflow( + type="custom", + tasks=[ + Task(type="custom", title="Step 1", content="Gathered data"), + Task(type="custom", title="Step 2", content="Analyzed results"), + ], + ), + ) + messages = converter.workflow_to_input(workflow_item) + # Returns list of messages for each task + """ + messages: list[ChatMessage] = [] + for task in item.workflow.tasks: + if task.type != "custom" or (not task.title and not task.content): + continue + + title = task.title or "" + content = task.content or "" + task_text = f"{title}: {content}" if title and content else title or content + text = ( + "A message was displayed to the user that the following task was performed:\n" + f"\n{task_text}\n" + ) + + messages.append(ChatMessage(role=Role.USER, text=text)) + + return messages if messages else None + + def widget_to_input(self, item: WidgetItem) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit WidgetItem to Agent Framework ChatMessage(s). + + This method is called internally by `to_agent_input()`. Override this method + to customize how widgets are converted. + + The default implementation converts the widget to a JSON representation + and includes it in a user message, allowing the model to understand what + UI element was displayed to the user. + + Args: + item: The ChatKit widget item to convert. + + Returns: + A ChatMessage describing the widget, or None to skip. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + + Examples: + .. code-block:: python + + # Widget item + from chatkit.widgets import Card, Text + + widget_item = WidgetItem( + id="widget_1", + thread_id="thread_1", + created_at=datetime.now(), + widget=Card(children=[Text(value="Hello")]), + ) + message = converter.widget_to_input(widget_item) + # Returns message with JSON representation of the widget + """ + try: + widget_json = item.widget.model_dump_json(exclude_unset=True, exclude_none=True) + text = f"The following graphical UI widget (id: {item.id}) was displayed to the user:{widget_json}" + return ChatMessage(role=Role.USER, text=text) + except Exception: + # If JSON serialization fails, skip the widget + return None + + async def assistant_message_to_input(self, item: AssistantMessageItem) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit AssistantMessageItem to Agent Framework ChatMessage(s). + + The default implementation extracts text from all content parts and creates + an assistant message. + + Args: + item: The ChatKit assistant message item to convert. + + Returns: + A ChatMessage with assistant role, or None to skip. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + """ + # Extract text from all content parts + text_parts = [content.text for content in item.content] + if not text_parts: + return None + + return ChatMessage(role=Role.ASSISTANT, text="".join(text_parts)) + + async def client_tool_call_to_input(self, item: ClientToolCallItem) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit ClientToolCallItem to Agent Framework ChatMessage(s). + + The default implementation converts completed tool calls into function call + and result content. + + Args: + item: The ChatKit client tool call item to convert. + + Returns: + A list containing function call and result messages, or None for pending calls. + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + """ + if item.status == "pending": + # Skip pending tool calls - they cannot be sent to the model + return None + + import json + + # Create function call message + function_call_msg = ChatMessage( + role=Role.ASSISTANT, + contents=[ + FunctionCallContent( + call_id=item.call_id, + name=item.name, + arguments=json.dumps(item.arguments), + ) + ], + ) + + # Create function result message + function_result_msg = ChatMessage( + role=Role.TOOL, + contents=[ + FunctionResultContent( + call_id=item.call_id, + result=json.dumps(item.output) if item.output is not None else "", + ) + ], + ) + + return [function_call_msg, function_result_msg] + + async def end_of_turn_to_input(self, item: EndOfTurnItem) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit EndOfTurnItem to Agent Framework ChatMessage(s). + + The default implementation skips end-of-turn markers as they are only UI hints. + + Args: + item: The ChatKit end-of-turn item to convert. + + Returns: + None (end-of-turn items are not converted). + + Note: + Instead of calling this method directly, use `to_agent_input()` which handles + all ThreadItem types and provides proper message ordering. + """ + # End-of-turn is only used for UI hints - skip it + return None + + async def _thread_item_to_input_item( + self, + item: ThreadItem, + is_last_message: bool = True, + ) -> list[ChatMessage]: + """Internal method to convert a single ThreadItem to ChatMessage(s). + + Args: + item: The thread item to convert. + is_last_message: Whether this is the last item in the thread. + + Returns: + A list of ChatMessage objects (may be empty). + """ + match item: + case UserMessageItem(): + out = await self.user_message_to_input(item, is_last_message) or [] + return out if isinstance(out, list) else [out] + case AssistantMessageItem(): + out = await self.assistant_message_to_input(item) or [] + return out if isinstance(out, list) else [out] + case ClientToolCallItem(): + out = await self.client_tool_call_to_input(item) or [] + return out if isinstance(out, list) else [out] + case EndOfTurnItem(): + out = await self.end_of_turn_to_input(item) or [] + return out if isinstance(out, list) else [out] + case WidgetItem(): + out = self.widget_to_input(item) or [] + return out if isinstance(out, list) else [out] + case WorkflowItem(): + out = self.workflow_to_input(item) or [] + return out if isinstance(out, list) else [out] + case TaskItem(): + out = self.task_to_input(item) or [] + return out if isinstance(out, list) else [out] + case HiddenContextItem(): + out = self.hidden_context_to_input(item) or [] + return out if isinstance(out, list) else [out] + case _: + assert_never(item) + + async def to_agent_input( + self, + thread_items: Sequence[ThreadItem] | ThreadItem, + ) -> list[ChatMessage]: + """Convert ChatKit thread items to Agent Framework ChatMessages. + + This is the main entry point for converting ChatKit thread items. It handles + all ThreadItem types (UserMessageItem, AssistantMessageItem, TaskItem, etc.) + and calls the appropriate conversion method for each. + + Args: + thread_items: A single ThreadItem or a sequence of ThreadItems to convert. + + Returns: + A list of ChatMessage objects that can be sent to an Agent Framework agent. + + Examples: + .. code-block:: python + + from agent_framework_chatkit import ThreadItemConverter + + converter = ThreadItemConverter() + + # Convert a single thread item + messages = await converter.to_agent_input(user_message_item) + + # Convert multiple thread items + messages = await converter.to_agent_input([user_message_item, assistant_message_item, task_item]) + + # Use with agent + from agent_framework import ChatAgent + + agent = ChatAgent(...) + response = await agent.run_stream(messages) + """ + thread_items = list(thread_items) if isinstance(thread_items, Sequence) else [thread_items] + + output: list[ChatMessage] = [] + for item in thread_items: + output.extend( + await self._thread_item_to_input_item( + item, + is_last_message=item is thread_items[-1], + ) + ) + return output + + +# Default converter instance +_DEFAULT_CONVERTER = ThreadItemConverter() + + +async def simple_to_agent_input(thread_items: Sequence[ThreadItem] | ThreadItem) -> list[ChatMessage]: + """Helper function that uses the default ThreadItemConverter. + + This function provides a quick way to get started with ChatKit integration + without needing to create a custom ThreadItemConverter instance. + + Args: + thread_items: A single ThreadItem or a sequence of ThreadItems to convert. + + Returns: + A list of ChatMessage objects that can be sent to an Agent Framework agent. + + Examples: + .. code-block:: python + + from agent_framework_chatkit import simple_to_agent_input + + # Convert a single item + messages = await simple_to_agent_input(user_message_item) + + # Convert multiple items + messages = await simple_to_agent_input([user_message_item, assistant_message_item, task_item]) + """ + return await _DEFAULT_CONVERTER.to_agent_input(thread_items) diff --git a/python/packages/chatkit/agent_framework_chatkit/_streaming.py b/python/packages/chatkit/agent_framework_chatkit/_streaming.py new file mode 100644 index 0000000000..daeaa0b4ab --- /dev/null +++ b/python/packages/chatkit/agent_framework_chatkit/_streaming.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Streaming utilities for converting Agent Framework responses to ChatKit events.""" + +import uuid +from collections.abc import AsyncIterable, AsyncIterator, Callable +from datetime import datetime + +from agent_framework import AgentRunResponseUpdate, TextContent +from chatkit.types import ( + AssistantMessageContent, + AssistantMessageContentPartTextDelta, + AssistantMessageItem, + ThreadItemAddedEvent, + ThreadItemDoneEvent, + ThreadItemUpdated, + ThreadStreamEvent, +) + + +async def stream_agent_response( + response_stream: AsyncIterable[AgentRunResponseUpdate], + thread_id: str, + generate_id: Callable[[str], str] | None = None, +) -> AsyncIterator[ThreadStreamEvent]: + """Convert a streamed AgentRunResponseUpdate from Agent Framework to ChatKit events. + + This helper function takes a stream of AgentRunResponseUpdate objects from + a Microsoft Agent Framework agent and converts them to ChatKit ThreadStreamEvent + objects that can be consumed by the ChatKit UI. + + The function supports real-time token-by-token streaming by emitting + ThreadItemUpdated events with AssistantMessageContentPartTextDelta for each + text chunk as it arrives from the agent. + + Args: + response_stream: An async iterable of AgentRunResponseUpdate objects + from an Agent Framework agent. + thread_id: The ChatKit thread ID for the conversation. + generate_id: Optional function to generate IDs for ChatKit items. + If not provided, simple incremental IDs will be used. + + Yields: + ThreadStreamEvent: ChatKit events representing the agent's response, + including incremental text deltas for streaming display. + """ + # Use provided ID generator or create default one + if generate_id is None: + + def _default_id_generator(item_type: str) -> str: + return f"{item_type}_{uuid.uuid4().hex[:8]}" + + message_id = _default_id_generator("msg") + else: + message_id = generate_id("msg") + + # Track if we've started the message + message_started = False + accumulated_text = "" + content_index = 0 + + async for update in response_stream: + # Start the assistant message if not already started + if not message_started: + assistant_message = AssistantMessageItem( + id=message_id, + thread_id=thread_id, + type="assistant_message", + content=[], + created_at=datetime.now(), + ) + + yield ThreadItemAddedEvent(type="thread.item.added", item=assistant_message) + message_started = True + + # Process the update content + if update.contents: + for content in update.contents: + # Handle text content - only TextContent has a text attribute + if isinstance(content, TextContent) and content.text is not None: + # Yield incremental text delta for streaming display + yield ThreadItemUpdated( + type="thread.item.updated", + item_id=message_id, + update=AssistantMessageContentPartTextDelta( + content_index=content_index, + delta=content.text, + ), + ) + accumulated_text += content.text + + # Finalize the message + if message_started: + final_message = AssistantMessageItem( + id=message_id, + thread_id=thread_id, + type="assistant_message", + content=[AssistantMessageContent(type="output_text", text=accumulated_text, annotations=[])] + if accumulated_text + else [], + created_at=datetime.now(), + ) + + yield ThreadItemDoneEvent(type="thread.item.done", item=final_message) diff --git a/python/packages/chatkit/agent_framework_chatkit/py.typed b/python/packages/chatkit/agent_framework_chatkit/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml new file mode 100644 index 0000000000..31248cf819 --- /dev/null +++ b/python/packages/chatkit/pyproject.toml @@ -0,0 +1,89 @@ +[project] +name = "agent-framework-chatkit" +description = "OpenAI ChatKit integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b251001" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core", + "openai-chatkit>=1.1.0,<2.0.0", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.ruff.lint] +ignore = ["RUF029"] + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extend = "../../pyproject.toml" +exclude = ['tests', 'chatkit-python', 'openai-chatkit-advanced-samples'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_chatkit"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit" +test = "pytest --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" \ No newline at end of file diff --git a/python/packages/chatkit/tests/__init__.py b/python/packages/chatkit/tests/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/chatkit/tests/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py new file mode 100644 index 0000000000..457017f647 --- /dev/null +++ b/python/packages/chatkit/tests/test_converter.py @@ -0,0 +1,426 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for ChatKit to Agent Framework converter utilities.""" + +from unittest.mock import Mock + +import pytest +from agent_framework import ChatMessage, Role, TextContent +from chatkit.types import UserMessageTextContent + +from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input + + +class TestThreadItemConverter: + """Tests for ThreadItemConverter class.""" + + @pytest.fixture + def converter(self): + """Create a ThreadItemConverter instance for testing.""" + return ThreadItemConverter() + + async def test_to_agent_input_none(self, converter): + """Test converting empty list returns empty list.""" + result = await converter.to_agent_input([]) + assert result == [] + + async def test_to_agent_input_with_text(self, converter): + """Test converting user message with text content.""" + from datetime import datetime + + from chatkit.types import UserMessageItem + + input_item = UserMessageItem( + id="msg_1", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[UserMessageTextContent(text="Hello, how can you help me?")], + attachments=[], + inference_options={}, + ) + + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + assert isinstance(result[0], ChatMessage) + assert result[0].role == Role.USER + assert result[0].text == "Hello, how can you help me?" + + async def test_to_agent_input_empty_text(self, converter): + """Test converting user message with empty or whitespace-only text.""" + from datetime import datetime + + from chatkit.types import UserMessageItem + + input_item = UserMessageItem( + id="msg_1", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[UserMessageTextContent(text=" ")], + attachments=[], + inference_options={}, + ) + + result = await converter.to_agent_input(input_item) + assert result == [] + + async def test_to_agent_input_no_content(self, converter): + """Test converting user message with no content.""" + from datetime import datetime + + from chatkit.types import UserMessageItem + + input_item = UserMessageItem( + id="msg_1", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[], + attachments=[], + inference_options={}, + ) + + result = await converter.to_agent_input(input_item) + assert result == [] + + async def test_to_agent_input_multiple_content_parts(self, converter): + """Test converting user message with multiple text content parts.""" + from datetime import datetime + + from chatkit.types import UserMessageItem + + input_item = UserMessageItem( + id="msg_1", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[ + UserMessageTextContent(text="Hello "), + UserMessageTextContent(text="world!"), + ], + attachments=[], + inference_options={}, + ) + + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + assert result[0].text == "Hello world!" + + def test_hidden_context_to_input(self, converter): + """Test converting hidden context item to ChatMessage.""" + hidden_item = Mock() + hidden_item.content = "This is hidden context information" + + result = converter.hidden_context_to_input(hidden_item) + + assert isinstance(result, ChatMessage) + assert result.role == Role.SYSTEM + assert result.text == "This is hidden context information" + + def test_tag_to_message_content(self, converter): + """Test converting tag to message content.""" + from chatkit.types import UserMessageTagContent + + tag = UserMessageTagContent( + type="input_tag", + id="tag_1", + text="john", + data={"name": "John Doe"}, + interactive=False, + ) + + result = converter.tag_to_message_content(tag) + assert isinstance(result, TextContent) + # Since data is a dict, getattr won't work, so it will fall back to text + assert result.text == "Name:john" + + def test_tag_to_message_content_no_name(self, converter): + """Test converting tag with no name to message content.""" + from chatkit.types import UserMessageTagContent + + tag = UserMessageTagContent( + type="input_tag", + id="tag_2", + text="jane", + data={}, + interactive=False, + ) + + result = converter.tag_to_message_content(tag) + assert isinstance(result, TextContent) + assert result.text == "Name:jane" + + async def test_attachment_to_message_content_file_without_fetcher(self, converter): + """Test that FileAttachment without data fetcher returns None.""" + from chatkit.types import FileAttachment + + attachment = FileAttachment( + id="file_123", + name="document.pdf", + mime_type="application/pdf", + type="file", + ) + + result = await converter.attachment_to_message_content(attachment) + assert result is None + + async def test_attachment_to_message_content_image_with_preview_url(self, converter): + """Test that ImageAttachment with preview_url creates UriContent.""" + from agent_framework import UriContent + from chatkit.types import ImageAttachment + + attachment = ImageAttachment( + id="img_123", + name="photo.jpg", + mime_type="image/jpeg", + type="image", + preview_url="https://example.com/photo.jpg", + ) + + result = await converter.attachment_to_message_content(attachment) + assert isinstance(result, UriContent) + assert result.uri == "https://example.com/photo.jpg" + assert result.media_type == "image/jpeg" + + async def test_attachment_to_message_content_with_data_fetcher(self): + """Test attachment conversion with data fetcher.""" + from agent_framework import DataContent + from chatkit.types import FileAttachment + + # Mock data fetcher + async def fetch_data(attachment_id: str) -> bytes: + return b"file content data" + + converter = ThreadItemConverter(attachment_data_fetcher=fetch_data) + + attachment = FileAttachment( + id="file_123", + name="document.pdf", + mime_type="application/pdf", + type="file", + ) + + result = await converter.attachment_to_message_content(attachment) + assert isinstance(result, DataContent) + assert result.media_type == "application/pdf" + + async def test_to_agent_input_with_image_attachment(self): + """Test converting user message with text and image attachment.""" + from datetime import datetime + + from agent_framework import UriContent + from chatkit.types import ImageAttachment, UserMessageItem + + attachment = ImageAttachment( + id="img_123", + name="photo.jpg", + mime_type="image/jpeg", + type="image", + preview_url="https://example.com/photo.jpg", + ) + + input_item = UserMessageItem( + id="msg_1", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[UserMessageTextContent(text="Check out this photo!")], + attachments=[attachment], + inference_options={}, + ) + + converter = ThreadItemConverter() + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + message = result[0] + assert message.role == Role.USER + assert len(message.contents) == 2 + + # First content should be text + assert isinstance(message.contents[0], TextContent) + assert message.contents[0].text == "Check out this photo!" + + # Second content should be UriContent for the image + assert isinstance(message.contents[1], UriContent) + assert message.contents[1].uri == "https://example.com/photo.jpg" + assert message.contents[1].media_type == "image/jpeg" + + async def test_to_agent_input_with_file_attachment_and_fetcher(self): + """Test converting user message with file attachment using data fetcher.""" + from datetime import datetime + + from agent_framework import DataContent + from chatkit.types import FileAttachment, UserMessageItem + + attachment = FileAttachment( + id="file_123", + name="report.pdf", + mime_type="application/pdf", + type="file", + ) + + input_item = UserMessageItem( + id="msg_1", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[UserMessageTextContent(text="Here's the document")], + attachments=[attachment], + inference_options={}, + ) + + # Create converter with data fetcher + async def fetch_data(attachment_id: str) -> bytes: + return b"PDF content data" + + converter = ThreadItemConverter(attachment_data_fetcher=fetch_data) + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + message = result[0] + assert len(message.contents) == 2 + + # First content should be text + assert isinstance(message.contents[0], TextContent) + + # Second content should be DataContent for the file + assert isinstance(message.contents[1], DataContent) + assert message.contents[1].media_type == "application/pdf" + + def test_task_to_input(self, converter): + """Test converting TaskItem to ChatMessage.""" + from datetime import datetime + + from chatkit.types import CustomTask, TaskItem + + task_item = TaskItem( + id="task_1", + thread_id="thread_1", + created_at=datetime.now(), + type="task", + task=CustomTask(type="custom", title="Analysis", content="Analyzed the data"), + ) + + result = converter.task_to_input(task_item) + assert isinstance(result, ChatMessage) + assert result.role == Role.USER + assert "Analysis: Analyzed the data" in result.text + assert "" in result.text + + def test_task_to_input_no_custom_task(self, converter): + """Test that non-custom tasks return None.""" + from datetime import datetime + + from chatkit.types import TaskItem, ThoughtTask + + task_item = TaskItem( + id="task_1", + thread_id="thread_1", + created_at=datetime.now(), + type="task", + task=ThoughtTask(type="thought", title="Think", content="Thinking..."), + ) + + result = converter.task_to_input(task_item) + assert result is None + + def test_workflow_to_input(self, converter): + """Test converting WorkflowItem to ChatMessages.""" + from datetime import datetime + + from chatkit.types import CustomTask, Workflow, WorkflowItem + + workflow_item = WorkflowItem( + id="wf_1", + thread_id="thread_1", + created_at=datetime.now(), + type="workflow", + workflow=Workflow( + type="custom", + tasks=[ + CustomTask(type="custom", title="Step 1", content="First step"), + CustomTask(type="custom", title="Step 2", content="Second step"), + ], + ), + ) + + result = converter.workflow_to_input(workflow_item) + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(msg, ChatMessage) for msg in result) + assert "Step 1: First step" in result[0].text + assert "Step 2: Second step" in result[1].text + + def test_workflow_to_input_empty(self, converter): + """Test that workflows with no custom tasks return None.""" + from datetime import datetime + + from chatkit.types import Workflow, WorkflowItem + + workflow_item = WorkflowItem( + id="wf_1", + thread_id="thread_1", + created_at=datetime.now(), + type="workflow", + workflow=Workflow(type="custom", tasks=[]), + ) + + result = converter.workflow_to_input(workflow_item) + assert result is None + + def test_widget_to_input(self, converter): + """Test converting WidgetItem to ChatMessage.""" + from datetime import datetime + + from chatkit.types import WidgetItem + from chatkit.widgets import Card, Text + + widget_item = WidgetItem( + id="widget_1", + thread_id="thread_1", + created_at=datetime.now(), + type="widget", + widget=Card(key="card1", children=[Text(value="Hello")]), + ) + + result = converter.widget_to_input(widget_item) + assert isinstance(result, ChatMessage) + assert result.role == Role.USER + assert "widget_1" in result.text + assert "graphical UI widget" in result.text + + +class TestSimpleToAgentInput: + """Tests for simple_to_agent_input helper function.""" + + async def test_simple_to_agent_input_empty_list(self): + """Test simple conversion with empty list.""" + result = await simple_to_agent_input([]) + assert result == [] + + async def test_simple_to_agent_input_with_text(self): + """Test simple conversion with text content.""" + from datetime import datetime + + from chatkit.types import UserMessageItem + + input_item = UserMessageItem( + id="msg_1", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[UserMessageTextContent(text="Test message")], + attachments=[], + inference_options={}, + ) + + result = await simple_to_agent_input(input_item) + + assert len(result) == 1 + assert isinstance(result[0], ChatMessage) + assert result[0].role == Role.USER + assert result[0].text == "Test message" diff --git a/python/packages/chatkit/tests/test_streaming.py b/python/packages/chatkit/tests/test_streaming.py new file mode 100644 index 0000000000..2e5041613a --- /dev/null +++ b/python/packages/chatkit/tests/test_streaming.py @@ -0,0 +1,142 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for Agent Framework to ChatKit streaming utilities.""" + +from unittest.mock import Mock + +from agent_framework import AgentRunResponseUpdate, Role, TextContent +from chatkit.types import ( + ThreadItemAddedEvent, + ThreadItemDoneEvent, + ThreadItemUpdated, +) + +from agent_framework_chatkit import stream_agent_response + + +class TestStreamAgentResponse: + """Tests for stream_agent_response function.""" + + async def test_stream_empty_response(self): + """Test streaming empty response.""" + + async def empty_stream(): + return + yield # Make it a generator + + events = [] + async for event in stream_agent_response(empty_stream(), thread_id="test_thread"): + events.append(event) + + assert len(events) == 0 + + async def test_stream_single_text_update(self): + """Test streaming single text update.""" + + async def single_update_stream(): + yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello world")]) + + events = [] + async for event in stream_agent_response(single_update_stream(), thread_id="test_thread"): + events.append(event) + + # Should have: item_added, item_updated (delta), item_done + assert len(events) == 3 + + # Check event types + assert isinstance(events[0], ThreadItemAddedEvent) + assert isinstance(events[1], ThreadItemUpdated) + assert isinstance(events[2], ThreadItemDoneEvent) + + # Check delta event + assert events[1].update.delta == "Hello world" + + # Check final message content + assert len(events[2].item.content) == 1 + assert events[2].item.content[0].text == "Hello world" + + async def test_stream_multiple_text_updates(self): + """Test streaming multiple text updates.""" + + async def multiple_updates_stream(): + yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello ")]) + yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="world!")]) + + events = [] + async for event in stream_agent_response(multiple_updates_stream(), thread_id="test_thread"): + events.append(event) + + # Should have: item_added, item_updated (delta 1), item_updated (delta 2), item_done + assert len(events) == 4 + + # Check event types + assert isinstance(events[0], ThreadItemAddedEvent) + assert isinstance(events[1], ThreadItemUpdated) + assert isinstance(events[2], ThreadItemUpdated) + assert isinstance(events[3], ThreadItemDoneEvent) + + # Check delta events + assert events[1].update.delta == "Hello " + assert events[2].update.delta == "world!" + + # Check final accumulated text + final_message_event = events[-1] + assert isinstance(final_message_event, ThreadItemDoneEvent) + assert final_message_event.item.content[0].text == "Hello world!" + + async def test_stream_with_custom_id_generator(self): + """Test streaming with custom ID generator.""" + + def custom_id_generator(item_type: str) -> str: + return f"custom_{item_type}_123" + + async def single_update_stream(): + yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Test")]) + + events = [] + async for event in stream_agent_response( + single_update_stream(), thread_id="test_thread", generate_id=custom_id_generator + ): + events.append(event) + + # Check that custom IDs are used + message_added_event = events[0] + assert message_added_event.item.id == "custom_msg_123" + + async def test_stream_empty_content_updates(self): + """Test streaming updates with empty content.""" + + async def empty_content_stream(): + yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[]) + yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=None) + + events = [] + async for event in stream_agent_response(empty_content_stream(), thread_id="test_thread"): + events.append(event) + + # Should have item_added and item_done + assert len(events) == 2 + assert isinstance(events[0], ThreadItemAddedEvent) + assert isinstance(events[1], ThreadItemDoneEvent) + + # Final message should have empty content + assert len(events[1].item.content) == 0 + + async def test_stream_non_text_content(self): + """Test streaming updates with non-text content.""" + # Mock a content object without text attribute + non_text_content = Mock() + # Don't set text attribute + del non_text_content.text + + async def non_text_stream(): + yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[non_text_content]) + + events = [] + async for event in stream_agent_response(non_text_stream(), thread_id="test_thread"): + events.append(event) + + # Should have item_added and item_done, but no content since no text + assert len(events) == 2 + assert isinstance(events[0], ThreadItemAddedEvent) + assert isinstance(events[1], ThreadItemDoneEvent) diff --git a/python/packages/core/agent_framework/chatkit/__init__.py b/python/packages/core/agent_framework/chatkit/__init__.py new file mode 100644 index 0000000000..163e6b412d --- /dev/null +++ b/python/packages/core/agent_framework/chatkit/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib +from typing import Any + +PACKAGE_NAME = "agent_framework_chatkit" +PACKAGE_EXTRA = "chatkit" +_IMPORTS = ["__version__", "ThreadItemConverter", "simple_to_agent_input", "stream_agent_response"] + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + try: + return getattr(importlib.import_module(PACKAGE_NAME), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + ) from exc + raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + + +def __dir__() -> list[str]: + return _IMPORTS diff --git a/python/packages/core/agent_framework/chatkit/__init__.pyi b/python/packages/core/agent_framework/chatkit/__init__.pyi new file mode 100644 index 0000000000..9bd90e638d --- /dev/null +++ b/python/packages/core/agent_framework/chatkit/__init__.pyi @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_chatkit import ( + ThreadItemConverter, + __version__, + simple_to_agent_input, + stream_agent_response, +) + +__all__ = ["ThreadItemConverter", "__version__", "simple_to_agent_input", "stream_agent_response"] diff --git a/python/pyproject.toml b/python/pyproject.toml index 5b7d8fee8d..8db0916229 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "agent-framework-a2a", "agent-framework-anthropic", "agent-framework-azure-ai", + "agent-framework-chatkit", "agent-framework-copilotstudio", "agent-framework-devui", "agent-framework-lab", @@ -89,6 +90,7 @@ agent-framework = { workspace = true } agent-framework-core = { workspace = true } agent-framework-a2a = { workspace = true } agent-framework-azure-ai = { workspace = true } +agent-framework-chatkit = { workspace = true } agent-framework-copilotstudio = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } @@ -240,6 +242,7 @@ pytest --import-mode=importlib --cov=agent_framework --cov=agent_framework_a2a --cov=agent_framework_azure_ai +--cov=agent_framework_chatkit --cov=agent_framework_copilotstudio --cov=agent_framework_mem0 --cov=agent_framework_redis diff --git a/python/samples/demos/chatkit-integration/.gitignore b/python/samples/demos/chatkit-integration/.gitignore new file mode 100644 index 0000000000..deb912b2f6 --- /dev/null +++ b/python/samples/demos/chatkit-integration/.gitignore @@ -0,0 +1,4 @@ +*.db +*.db-shm +*.db-wal +uploads/ \ No newline at end of file diff --git a/python/samples/demos/chatkit-integration/README.md b/python/samples/demos/chatkit-integration/README.md new file mode 100644 index 0000000000..28dfef398e --- /dev/null +++ b/python/samples/demos/chatkit-integration/README.md @@ -0,0 +1,268 @@ +# ChatKit Integration Sample with Weather Agent and Image Analysis + +This sample demonstrates how to integrate Microsoft Agent Framework with OpenAI ChatKit. It provides a complete implementation of a weather assistant with interactive widget visualization, image analysis, and file upload support. + +**Features:** + +- Weather information with interactive widgets +- Image analysis using vision models +- Current time queries +- File upload with attachment storage +- Chat interface with streaming responses +- City selector widget with one-click weather + +## Architecture + +```mermaid +graph TB + subgraph Frontend["React Frontend (ChatKit UI)"] + UI[ChatKit Components] + Upload[File Upload] + end + + subgraph Backend["FastAPI Server"] + FastAPI[FastAPI Endpoints] + + subgraph ChatKit["WeatherChatKitServer"] + Respond[respond method] + Action[action method] + end + + subgraph Stores["Data & Storage Layer"] + SQLite[SQLiteStore
Store Protocol] + AttStore[FileBasedAttachmentStore
AttachmentStore Protocol] + DB[(SQLite DB
chatkit_demo.db)] + Files[/uploads directory/] + end + + subgraph Integration["Agent Framework Integration"] + Converter[ThreadItemConverter] + Streamer[stream_agent_response] + Agent[ChatAgent] + end + + Widgets[Widget Rendering
render_weather_widget
render_city_selector_widget] + end + + subgraph Azure["Azure AI"] + Foundry[GPT-5
with Vision] + end + + UI -->|HTTP POST /chatkit| FastAPI + Upload -->|HTTP POST /upload/id| FastAPI + + FastAPI --> ChatKit + + ChatKit -->|save/load threads| SQLite + ChatKit -->|save/load attachments| AttStore + ChatKit -->|convert messages| Converter + + SQLite -.->|persist| DB + AttStore -.->|save files| Files + AttStore -.->|save metadata| SQLite + + Converter -->|ChatMessage array| Agent + Agent -->|AgentRunResponseUpdate| Streamer + Streamer -->|ThreadStreamEvent| ChatKit + + ChatKit --> Widgets + Widgets -->|WidgetItem| ChatKit + + Agent <-->|Chat Completions API| Foundry + + ChatKit -->|ThreadStreamEvent| FastAPI + FastAPI -->|SSE Stream| UI + + style ChatKit fill:#e1f5ff + style Stores fill:#fff4e1 + style Integration fill:#f0e1ff + style Azure fill:#e1ffe1 +``` + +### Server Implementation + +The sample implements a ChatKit server using the `ChatKitServer` base class from the `chatkit` package: + +**Core Components:** + +- **`WeatherChatKitServer`**: Custom ChatKit server implementation that: + + - Extends `ChatKitServer[dict[str, Any]]` + - Uses Agent Framework's `ChatAgent` with Azure OpenAI + - Converts ChatKit messages to Agent Framework format using `ThreadItemConverter` + - Streams responses back to ChatKit using `stream_agent_response` + - Creates and streams interactive widgets after agent responses + +- **`SQLiteStore`**: Data persistence layer that: + + - Implements the `Store[dict[str, Any]]` protocol from ChatKit + - Persists threads, messages, and attachment metadata in SQLite + - Provides thread management and item history + - Stores attachment metadata for the upload lifecycle + +- **`FileBasedAttachmentStore`**: File storage implementation that: + - Implements the `AttachmentStore[dict[str, Any]]` protocol from ChatKit + - Stores uploaded files on the local filesystem (in `./uploads` directory) + - Generates upload URLs for two-phase file upload + - Saves attachment metadata to the data store for upload tracking + - Provides preview URLs for images + +**Key Integration Points:** + +```python +# Converting ChatKit messages to Agent Framework +converter = ThreadItemConverter( + attachment_data_fetcher=self._fetch_attachment_data +) +agent_messages = await converter.to_agent_input(user_message_item) + +# Running agent and streaming back to ChatKit +async for event in stream_agent_response( + self.weather_agent.run_stream(agent_messages), + thread_id=thread.id, +): + yield event + +# Streaming widgets +widget = render_weather_widget(weather_data) +async for event in stream_widget(thread_id=thread.id, widget=widget): + yield event +``` + +## Installation and Setup + +### Prerequisites + +- Python 3.10+ +- Node.js 18.18+ and npm 9+ +- Azure OpenAI service configured +- Azure CLI for authentication (`az login`) + +### Backend Setup + +1. **Install Python packages:** + +```bash +cd python/samples/demos/chatkit-integration +pip install agent-framework-chatkit fastapi uvicorn azure-identity +``` + +2. **Configure Azure OpenAI:** + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +export AZURE_OPENAI_API_VERSION="2024-06-01" +export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o" +``` + +3. **Authenticate with Azure:** + +```bash +az login +``` + +### Frontend Setup + +Install the Node.js dependencies: + +```bash +cd frontend +npm install +``` + +## How to Run + +### Start the Backend Server + +From the `chatkit-integration` directory: + +```bash +python app.py +``` + +Or with auto-reload for development: + +```bash +uvicorn app:app --host 127.0.0.1 --port 8001 --reload +``` + +The backend will start on `http://localhost:8001` + +### Start the Frontend Development Server + +In a new terminal, from the `frontend` directory: + +```bash +npm run dev +``` + +The frontend will start on `http://localhost:5171` + +### Access the Application + +Open your browser and navigate to: + +``` +http://localhost:5171 +``` + +You can now: + +- Ask about weather in any location (weather widgets display automatically) +- Upload images for analysis using the attachment button +- Get the current time +- Ask to see available cities and click city buttons for instant weather + +### Project Structure + +``` +chatkit-integration/ +├── app.py # FastAPI backend with ChatKitServer implementation +├── store.py # SQLiteStore implementation +├── attachment_store.py # FileBasedAttachmentStore implementation +├── weather_widget.py # Widget rendering functions +├── chatkit_demo.db # SQLite database (auto-created) +├── uploads/ # Uploaded files directory (auto-created) +└── frontend/ + ├── package.json + ├── vite.config.ts + ├── index.html + └── src/ + ├── main.tsx + └── App.tsx # ChatKit UI integration +``` + +### Configuration + +You can customize the application by editing constants at the top of `app.py`: + +```python +# Server configuration +SERVER_HOST = "127.0.0.1" # Bind to localhost only for security (local dev) +SERVER_PORT = 8001 +SERVER_BASE_URL = f"http://localhost:{SERVER_PORT}" + +# Database configuration +DATABASE_PATH = "chatkit_demo.db" + +# File storage configuration +UPLOADS_DIRECTORY = "./uploads" + +# User context +DEFAULT_USER_ID = "demo_user" +``` + +### Sample Conversations + +Try these example queries: + +- "What's the weather like in Tokyo?" +- "Show me available cities" (displays interactive city selector) +- "What's the current time?" +- Upload an image and ask "What do you see in this image?" + +## Learn More + +- [Agent Framework Documentation](https://aka.ms/agent-framework) +- [ChatKit Documentation](https://platform.openai.com/docs/guides/chatkit) +- [Azure OpenAI Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/) diff --git a/python/samples/demos/chatkit-integration/__init__.py b/python/samples/demos/chatkit-integration/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/samples/demos/chatkit-integration/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/samples/demos/chatkit-integration/app.py b/python/samples/demos/chatkit-integration/app.py new file mode 100644 index 0000000000..ed5fd2dd6e --- /dev/null +++ b/python/samples/demos/chatkit-integration/app.py @@ -0,0 +1,538 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +ChatKit Integration Sample with Weather Agent and Image Analysis + +This sample demonstrates how to integrate Microsoft Agent Framework with OpenAI ChatKit +using a weather tool with widget visualization, image analysis, and Azure OpenAI. It shows +a complete ChatKit server implementation using Agent Framework agents with proper FastAPI +setup, interactive weather widgets, and vision capabilities for analyzing uploaded images. +""" + +import logging +from collections.abc import AsyncIterator, Callable +from datetime import datetime, timezone +from random import randint +from typing import Annotated, Any + +import uvicorn +from azure.identity import AzureCliCredential +from fastapi import FastAPI, File, Request, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse +from pydantic import Field + +# ============================================================================ +# Configuration Constants +# ============================================================================ + +# Server configuration +SERVER_HOST = "127.0.0.1" # Bind to localhost only for security (local dev) +SERVER_PORT = 8001 +SERVER_BASE_URL = f"http://localhost:{SERVER_PORT}" + +# Database configuration +DATABASE_PATH = "chatkit_demo.db" + +# File storage configuration +UPLOADS_DIRECTORY = "./uploads" + +# User context +DEFAULT_USER_ID = "demo_user" + +# Logging configuration +LOG_LEVEL = logging.INFO +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +# ============================================================================ +# Logging Setup +# ============================================================================ + +logging.basicConfig( + level=LOG_LEVEL, + format=LOG_FORMAT, + datefmt=LOG_DATE_FORMAT, +) +logger = logging.getLogger(__name__) + +# Agent Framework imports +from agent_framework import AgentRunResponseUpdate, ChatAgent, ChatMessage, FunctionResultContent, Role +from agent_framework.azure import AzureOpenAIChatClient + +# Agent Framework ChatKit integration +from agent_framework_chatkit import ThreadItemConverter, stream_agent_response + +# Local imports +from attachment_store import FileBasedAttachmentStore + +# ChatKit imports +from chatkit.actions import Action +from chatkit.server import ChatKitServer +from chatkit.store import StoreItemType, default_generate_id +from chatkit.types import ( + ThreadItemDoneEvent, + ThreadMetadata, + ThreadStreamEvent, + UserMessageItem, + WidgetItem, +) +from chatkit.widgets import WidgetRoot +from store import SQLiteStore +from weather_widget import ( + WeatherData, + city_selector_copy_text, + render_city_selector_widget, + render_weather_widget, + weather_widget_copy_text, +) + + +class WeatherResponse(str): + """A string response that also carries WeatherData for widget creation.""" + + def __new__(cls, text: str, weather_data: WeatherData): + instance = super().__new__(cls, text) + instance.weather_data = weather_data # type: ignore + return instance + + +async def stream_widget( + thread_id: str, + widget: WidgetRoot, + copy_text: str | None = None, + generate_id: Callable[[StoreItemType], str] = default_generate_id, +) -> AsyncIterator[ThreadStreamEvent]: + """Stream a ChatKit widget as a ThreadStreamEvent. + + This helper function creates a ChatKit widget item and yields it as a + ThreadItemDoneEvent that can be consumed by the ChatKit UI. + + Args: + thread_id: The ChatKit thread ID for the conversation. + widget: The ChatKit widget to display. + copy_text: Optional text representation of the widget for copy/paste. + generate_id: Optional function to generate IDs for ChatKit items. + + Yields: + ThreadStreamEvent: ChatKit event containing the widget. + """ + item_id = generate_id("message") + + widget_item = WidgetItem( + id=item_id, + thread_id=thread_id, + created_at=datetime.now(), + widget=widget, + copy_text=copy_text, + ) + + yield ThreadItemDoneEvent(type="thread.item.done", item=widget_item) + + +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location. + + Returns a string description with embedded WeatherData for widget creation. + """ + logger.info(f"Fetching weather for location: {location}") + + conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy", "foggy"] + temperature = randint(-5, 35) + condition = conditions[randint(0, len(conditions) - 1)] + + # Add some realistic details + humidity = randint(30, 90) + wind_speed = randint(5, 25) + + weather_data = WeatherData( + location=location, + condition=condition, + temperature=temperature, + humidity=humidity, + wind_speed=wind_speed, + ) + + logger.debug(f"Weather data generated: {condition}, {temperature}°C, {humidity}% humidity, {wind_speed} km/h wind") + + # Return a WeatherResponse that is both a string (for the LLM) and carries structured data + text = ( + f"Weather in {location}:\n" + f"• Condition: {condition.title()}\n" + f"• Temperature: {temperature}°C\n" + f"• Humidity: {humidity}%\n" + f"• Wind: {wind_speed} km/h" + ) + return WeatherResponse(text, weather_data) + + +def get_time() -> str: + """Get the current UTC time.""" + current_time = datetime.now(timezone.utc) + logger.info("Getting current UTC time") + return f"Current UTC time: {current_time.strftime('%Y-%m-%d %H:%M:%S')} UTC" + + +def show_city_selector() -> str: + """Show an interactive city selector widget to the user. + + This function triggers the display of a widget that allows users + to select from popular cities to get weather information. + + Returns a special marker string that will be detected to show the widget. + """ + logger.info("Activating city selector widget") + return "__SHOW_CITY_SELECTOR__" + + +class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): + """ChatKit server implementation using Agent Framework. + + This server integrates Agent Framework agents with ChatKit's server protocol, + providing weather information with interactive widgets and time queries through Azure OpenAI. + """ + + def __init__(self, data_store: SQLiteStore, attachment_store: FileBasedAttachmentStore): + super().__init__(data_store, attachment_store) + + logger.info("Initializing WeatherChatKitServer") + + # Create Agent Framework agent with Azure OpenAI + # For authentication, run `az login` command in terminal + try: + self.weather_agent = ChatAgent( + chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + instructions=( + "You are a helpful weather assistant with image analysis capabilities. " + "You can provide weather information for any location, tell the current time, " + "and analyze images that users upload. Be friendly and informative in your responses.\n\n" + "If a user asks to see a list of cities or wants to choose from available cities, " + "use the show_city_selector tool to display an interactive city selector.\n\n" + "When users upload images, you will automatically receive them and can analyze their content. " + "Describe what you see in detail and be helpful in answering questions about the images." + ), + tools=[get_weather, get_time, show_city_selector], + ) + logger.info("Weather agent initialized successfully with Azure OpenAI") + except Exception as e: + logger.error(f"Failed to initialize weather agent: {e}") + raise + + # Create ThreadItemConverter with attachment data fetcher + self.converter = ThreadItemConverter( + attachment_data_fetcher=self._fetch_attachment_data, + ) + + logger.info("WeatherChatKitServer initialized") + + async def _fetch_attachment_data(self, attachment_id: str) -> bytes: + """Fetch attachment binary data for the converter. + + Args: + attachment_id: The ID of the attachment to fetch. + + Returns: + The binary data of the attachment. + """ + return await attachment_store.read_attachment_bytes(attachment_id) + + async def respond( + self, + thread: ThreadMetadata, + input_user_message: UserMessageItem | None, + context: dict[str, Any], + ) -> AsyncIterator[ThreadStreamEvent]: + """Handle incoming user messages and generate responses. + + This method converts ChatKit messages to Agent Framework format using ThreadItemConverter, + runs the agent, converts the response back to ChatKit events using stream_agent_response, + and creates interactive weather widgets when weather data is queried. + """ + from agent_framework import FunctionResultContent + + if input_user_message is None: + logger.debug("Received None user message, skipping") + return + + logger.info(f"Processing message for thread: {thread.id}") + + try: + # Track weather data and city selector flag for this request + weather_data: WeatherData | None = None + show_city_selector = False + + # Convert ChatKit user message to Agent Framework ChatMessage using ThreadItemConverter + agent_messages = await self.converter.to_agent_input(input_user_message) + + if not agent_messages: + logger.warning("No messages after conversion") + return + + logger.info(f"Running agent with {len(agent_messages)} message(s)") + + # Run the Agent Framework agent with streaming + agent_stream = self.weather_agent.run_stream(agent_messages) + + # Create an intercepting stream that extracts function results while passing through updates + async def intercept_stream() -> AsyncIterator[AgentRunResponseUpdate]: + nonlocal weather_data, show_city_selector + async for update in agent_stream: + # Check for function results in the update + if update.contents: + for content in update.contents: + if isinstance(content, FunctionResultContent): + result = content.result + + # Check if it's a WeatherResponse (string subclass with weather_data attribute) + if isinstance(result, str) and hasattr(result, "weather_data"): + extracted_data = getattr(result, "weather_data", None) + if isinstance(extracted_data, WeatherData): + weather_data = extracted_data + logger.info(f"Weather data extracted: {weather_data.location}") + # Check if it's the city selector marker + elif isinstance(result, str) and result == "__SHOW_CITY_SELECTOR__": + show_city_selector = True + logger.info("City selector flag detected") + yield update + + # Stream updates as ChatKit events with interception + async for event in stream_agent_response( + intercept_stream(), + thread_id=thread.id, + ): + yield event + + # If weather data was collected during the tool call, create a widget + if weather_data is not None and isinstance(weather_data, WeatherData): + logger.info(f"Creating weather widget for location: {weather_data.location}") + # Create weather widget + widget = render_weather_widget(weather_data) + copy_text = weather_widget_copy_text(weather_data) + + # Stream the widget + async for widget_event in stream_widget(thread_id=thread.id, widget=widget, copy_text=copy_text): + yield widget_event + logger.debug("Weather widget streamed successfully") + + # If city selector should be shown, create and stream that widget + if show_city_selector: + logger.info("Creating city selector widget") + # Create city selector widget + selector_widget = render_city_selector_widget() + selector_copy_text = city_selector_copy_text() + + # Stream the widget + async for widget_event in stream_widget( + thread_id=thread.id, widget=selector_widget, copy_text=selector_copy_text + ): + yield widget_event + logger.debug("City selector widget streamed successfully") + + logger.info(f"Completed processing message for thread: {thread.id}") + + except Exception as e: + logger.error(f"Error processing message for thread {thread.id}: {e}", exc_info=True) + + async def action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: dict[str, Any], + ) -> AsyncIterator[ThreadStreamEvent]: + """Handle widget actions from the frontend. + + This method processes actions triggered by interactive widgets, + such as city selection from the city selector widget. + """ + + logger.info(f"Received action: {action.type} for thread: {thread.id}") + + if action.type == "city_selected": + # Extract city information from the action payload + city_label = action.payload.get("city_label", "Unknown") + + logger.info(f"City selected: {city_label}") + logger.debug(f"Action payload: {action.payload}") + + # Track weather data for this request + weather_data: WeatherData | None = None + + # Create an agent message asking about the weather + agent_messages = [ChatMessage(role=Role.USER, text=f"What's the weather in {city_label}?")] + + logger.debug(f"Processing weather query: {agent_messages[0].text}") + + # Run the Agent Framework agent with streaming + agent_stream = self.weather_agent.run_stream(agent_messages) + + # Create an intercepting stream that extracts function results while passing through updates + async def intercept_stream() -> AsyncIterator[AgentRunResponseUpdate]: + nonlocal weather_data + async for update in agent_stream: + # Check for function results in the update + if update.contents: + for content in update.contents: + if isinstance(content, FunctionResultContent): + result = content.result + + # Check if it's a WeatherResponse (string subclass with weather_data attribute) + if isinstance(result, str) and hasattr(result, "weather_data"): + extracted_data = getattr(result, "weather_data", None) + if isinstance(extracted_data, WeatherData): + weather_data = extracted_data + logger.info(f"Weather data extracted: {weather_data.location}") + yield update + + # Stream updates as ChatKit events with interception + async for event in stream_agent_response( + intercept_stream(), + thread_id=thread.id, + ): + yield event + + # If weather data was collected during the tool call, create a widget + if weather_data is not None and isinstance(weather_data, WeatherData): + logger.info(f"Creating weather widget for: {weather_data.location}") + # Create weather widget + widget = render_weather_widget(weather_data) + copy_text = weather_widget_copy_text(weather_data) + + # Stream the widget + async for widget_event in stream_widget(thread_id=thread.id, widget=widget, copy_text=copy_text): + yield widget_event + logger.debug("Weather widget created successfully from action") + else: + logger.warning("No weather data available to create widget after action") + + +# FastAPI application setup +app = FastAPI( + title="ChatKit Weather & Vision Agent", + description="Weather and image analysis assistant powered by Agent Framework and Azure OpenAI", + version="1.0.0", +) + +# Add CORS middleware to allow frontend connections +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify exact origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize data store and ChatKit server +logger.info("Initializing application components") +data_store = SQLiteStore(db_path=DATABASE_PATH) +attachment_store = FileBasedAttachmentStore( + uploads_dir=UPLOADS_DIRECTORY, + base_url=SERVER_BASE_URL, + data_store=data_store, +) +chatkit_server = WeatherChatKitServer(data_store, attachment_store) +logger.info("Application initialization complete") + + +@app.post("/chatkit") +async def chatkit_endpoint(request: Request): + """Main ChatKit endpoint that handles all ChatKit requests. + + This endpoint follows the ChatKit server protocol and handles both + streaming and non-streaming responses. + """ + logger.debug(f"Received ChatKit request from {request.client}") + request_body = await request.body() + + # Create context following the working examples pattern + context = {"request": request} + + try: + # Process the request using ChatKit server + result = await chatkit_server.process(request_body, context) + + # Return appropriate response type + if hasattr(result, "__aiter__"): # StreamingResult + logger.debug("Returning streaming response") + return StreamingResponse(result, media_type="text/event-stream") # type: ignore[arg-type] + # NonStreamingResult + logger.debug("Returning non-streaming response") + return Response(content=result.json, media_type="application/json") # type: ignore[union-attr] + except Exception as e: + logger.error(f"Error processing ChatKit request: {e}", exc_info=True) + raise + + +@app.post("/upload/{attachment_id}") +async def upload_file(attachment_id: str, file: UploadFile = File(...)): + """Handle file upload for two-phase upload. + + The client POSTs the file bytes here after creating the attachment + via the ChatKit attachments.create endpoint. + """ + logger.info(f"Receiving file upload for attachment: {attachment_id}") + + try: + # Read file contents + contents = await file.read() + + # Save to disk + file_path = attachment_store.get_file_path(attachment_id) + file_path.write_bytes(contents) + + logger.info(f"Saved {len(contents)} bytes to {file_path}") + + # Load the attachment metadata from the data store + attachment = await data_store.load_attachment(attachment_id, {"user_id": DEFAULT_USER_ID}) + + # Clear the upload_url since upload is complete + attachment.upload_url = None + + # Save the updated attachment back to the store + await data_store.save_attachment(attachment, {"user_id": DEFAULT_USER_ID}) + + # Return the attachment metadata as JSON + return JSONResponse(content=attachment.model_dump(mode="json")) + + except Exception as e: + logger.error(f"Error uploading file for attachment {attachment_id}: {e}", exc_info=True) + return JSONResponse(status_code=500, content={"error": f"Failed to upload file: {str(e)}"}) + + +@app.get("/preview/{attachment_id}") +async def preview_image(attachment_id: str): + """Serve image preview/thumbnail. + + For simplicity, this serves the full image. In production, you should + generate and cache thumbnails. + """ + logger.debug(f"Serving preview for attachment: {attachment_id}") + + try: + file_path = attachment_store.get_file_path(attachment_id) + + if not file_path.exists(): + return JSONResponse(status_code=404, content={"error": "File not found"}) + + # Determine media type from file extension or attachment metadata + # For simplicity, we'll try to load from the store + try: + attachment = await data_store.load_attachment(attachment_id, {"user_id": DEFAULT_USER_ID}) + media_type = attachment.mime_type + except Exception: + # Default to binary if we can't determine + media_type = "application/octet-stream" + + return FileResponse(file_path, media_type=media_type) + + except Exception as e: + logger.error(f"Error serving preview for attachment {attachment_id}: {e}", exc_info=True) + return JSONResponse(status_code=500, content={"error": str(e)}) + + +if __name__ == "__main__": + # Run the server + logger.info(f"Starting ChatKit Weather Agent server on {SERVER_HOST}:{SERVER_PORT}") + uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, log_level="info") diff --git a/python/samples/demos/chatkit-integration/attachment_store.py b/python/samples/demos/chatkit-integration/attachment_store.py new file mode 100644 index 0000000000..263af20f46 --- /dev/null +++ b/python/samples/demos/chatkit-integration/attachment_store.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""File-based AttachmentStore implementation for ChatKit. + +This module provides a simple AttachmentStore implementation that stores +uploaded files on the local filesystem. In production, you should use +cloud storage like S3, Azure Blob Storage, or Google Cloud Storage. +""" + +from pathlib import Path +from typing import Any, TYPE_CHECKING + +from chatkit.store import AttachmentStore +from chatkit.types import Attachment, AttachmentCreateParams, FileAttachment, ImageAttachment +from pydantic import AnyUrl + +if TYPE_CHECKING: + from store import SQLiteStore + + +class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]): + """File-based AttachmentStore that stores files on local disk. + + This implementation stores uploaded files in a local directory and provides + upload URLs that point to the FastAPI upload endpoint. It supports both + image and file attachments. + + Features: + - Stores files in a local uploads directory + - Generates upload URLs for two-phase upload + - Generates preview URLs for images + - Proper cleanup on deletion + + Note: This is for demonstration purposes. In production, use cloud storage + with signed URLs for better security and scalability. + """ + + def __init__( + self, + uploads_dir: str = "./uploads", + base_url: str = "http://localhost:8001", + data_store: "SQLiteStore | None" = None, + ): + """Initialize the file-based attachment store. + + Args: + uploads_dir: Directory where uploaded files will be stored + base_url: Base URL for generating upload and preview URLs + data_store: Optional data store to persist attachment metadata + """ + self.uploads_dir = Path(uploads_dir) + self.base_url = base_url.rstrip("/") + self.data_store = data_store + + # Create uploads directory if it doesn't exist + self.uploads_dir.mkdir(parents=True, exist_ok=True) + + def get_file_path(self, attachment_id: str) -> Path: + """Get the filesystem path for an attachment.""" + return self.uploads_dir / attachment_id + + async def delete_attachment(self, attachment_id: str, context: dict[str, Any]) -> None: + """Delete an attachment and its file from disk.""" + file_path = self.get_file_path(attachment_id) + if file_path.exists(): + file_path.unlink() + + async def create_attachment( + self, input: AttachmentCreateParams, context: dict[str, Any] + ) -> Attachment: + """Create an attachment with upload URL for two-phase upload. + + This creates the attachment metadata and returns upload URLs that + the client will use to POST the actual file bytes. + """ + # Generate unique ID for this attachment + attachment_id = self.generate_attachment_id(input.mime_type, context) + + # Generate upload URL that points to our FastAPI upload endpoint + upload_url = f"{self.base_url}/upload/{attachment_id}" + + # Create appropriate attachment type based on MIME type + if input.mime_type.startswith("image/"): + # For images, also provide a preview URL + preview_url = f"{self.base_url}/preview/{attachment_id}" + + attachment = ImageAttachment( + id=attachment_id, + type="image", + mime_type=input.mime_type, + name=input.name, + upload_url=AnyUrl(upload_url), + preview_url=AnyUrl(preview_url), + ) + else: + # For files, just provide upload URL + attachment = FileAttachment( + id=attachment_id, + type="file", + mime_type=input.mime_type, + name=input.name, + upload_url=AnyUrl(upload_url), + ) + + # Save attachment metadata to data store so it's available during upload + if self.data_store is not None: + await self.data_store.save_attachment(attachment, context) + + return attachment + + async def read_attachment_bytes(self, attachment_id: str) -> bytes: + """Read the raw bytes of an uploaded attachment. + + This is used by the ThreadItemConverter to create base64-encoded + content for sending to the Agent Framework. + """ + file_path = self.get_file_path(attachment_id) + if not file_path.exists(): + raise FileNotFoundError(f"Attachment {attachment_id} not found on disk") + + return file_path.read_bytes() diff --git a/python/samples/demos/chatkit-integration/frontend/index.html b/python/samples/demos/chatkit-integration/frontend/index.html new file mode 100644 index 0000000000..82837ef519 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/index.html @@ -0,0 +1,52 @@ + + + + + + ChatKit + Agent Framework Demo + + + + +
+

ChatKit + Agent Framework Demo

+

Simple weather assistant powered by Agent Framework and ChatKit

+
+
+ + + diff --git a/python/samples/demos/chatkit-integration/frontend/package-lock.json b/python/samples/demos/chatkit-integration/frontend/package-lock.json new file mode 100644 index 0000000000..9cf6bb6b86 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/package-lock.json @@ -0,0 +1,1437 @@ +{ + "name": "chatkit-agent-framework-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chatkit-agent-framework-demo", + "version": "0.1.0", + "dependencies": { + "@openai/chatkit-react": "^0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "typescript": "^5.4.0", + "vite": "^7.1.9" + }, + "engines": { + "node": ">=18.18", + "npm": ">=9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@openai/chatkit": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@openai/chatkit/-/chatkit-0.0.0.tgz", + "integrity": "sha512-9YomebDd2dpWFR3s1fiEtNknXmEC8QYt//2ConGjr/4geWdRqunEpO+i7yJXYEGLJbkmB4lxwKmbwWJA4pvpSg==", + "license": "MIT" + }, + "node_modules/@openai/chatkit-react": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@openai/chatkit-react/-/chatkit-react-0.0.0.tgz", + "integrity": "sha512-ppoAKiWKUJGIlKuFQ0mgPRVMAAjJ+PonAzdo1p7BQmTEZtwFI8vq6W7ZRN2UTfzZZIKbJ2diwU6ePbYSKsePuQ==", + "license": "MIT", + "dependencies": { + "@openai/chatkit": "0.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", + "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.24" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.13.5", + "@swc/core-darwin-x64": "1.13.5", + "@swc/core-linux-arm-gnueabihf": "1.13.5", + "@swc/core-linux-arm64-gnu": "1.13.5", + "@swc/core-linux-arm64-musl": "1.13.5", + "@swc/core-linux-x64-gnu": "1.13.5", + "@swc/core-linux-x64-musl": "1.13.5", + "@swc/core-win32-arm64-msvc": "1.13.5", + "@swc/core-win32-ia32-msvc": "1.13.5", + "@swc/core-win32-x64-msvc": "1.13.5" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.5.tgz", + "integrity": "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.5.tgz", + "integrity": "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.5.tgz", + "integrity": "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.5.tgz", + "integrity": "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.5.tgz", + "integrity": "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz", + "integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz", + "integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.5.tgz", + "integrity": "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.5.tgz", + "integrity": "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.5.tgz", + "integrity": "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", + "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.27", + "@swc/core": "^1.12.11" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", + "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + } + } +} diff --git a/python/samples/demos/chatkit-integration/frontend/package.json b/python/samples/demos/chatkit-integration/frontend/package.json new file mode 100644 index 0000000000..65d65d1d53 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "chatkit-agent-framework-demo", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "engines": { + "node": ">=18.18", + "npm": ">=9" + }, + "dependencies": { + "@openai/chatkit-react": "^0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "typescript": "^5.4.0", + "vite": "^7.1.9" + } +} \ No newline at end of file diff --git a/python/samples/demos/chatkit-integration/frontend/src/App.tsx b/python/samples/demos/chatkit-integration/frontend/src/App.tsx new file mode 100644 index 0000000000..13f42d17c9 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/src/App.tsx @@ -0,0 +1,33 @@ +import { ChatKit, useChatKit } from "@openai/chatkit-react"; + +const CHATKIT_API_URL = "/chatkit"; +const CHATKIT_API_DOMAIN_KEY = + import.meta.env.VITE_CHATKIT_API_DOMAIN_KEY ?? "domain_pk_localhost_dev"; + +export default function App() { + const chatkit = useChatKit({ + api: { + url: CHATKIT_API_URL, + domainKey: CHATKIT_API_DOMAIN_KEY, + uploadStrategy: { type: "two_phase" }, + }, + startScreen: { + greeting: "Hello! I'm your weather and image analysis assistant. Ask me about the weather in any location or upload images for me to analyze.", + prompts: [ + { label: "Weather in New York", prompt: "What's the weather in New York?" }, + { label: "Select City to Get Weather", prompt: "Show me the city selector for weather" }, + { label: "Current Time", prompt: "What time is it?" }, + { label: "Analyze an Image", prompt: "I'll upload an image for you to analyze" }, + ], + }, + composer: { + placeholder: "Ask about weather or upload an image...", + attachments: { + enabled: true, + accept: { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] }, + }, + }, + }); + + return ; +} diff --git a/python/samples/demos/chatkit-integration/frontend/src/main.tsx b/python/samples/demos/chatkit-integration/frontend/src/main.tsx new file mode 100644 index 0000000000..0937a0fa0f --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +const container = document.getElementById("root"); + +if (!container) { + throw new Error("Root element with id 'root' not found"); +} + +createRoot(container).render( + + + , +); diff --git a/python/samples/demos/chatkit-integration/frontend/src/vite-env.d.ts b/python/samples/demos/chatkit-integration/frontend/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/python/samples/demos/chatkit-integration/frontend/tsconfig.json b/python/samples/demos/chatkit-integration/frontend/tsconfig.json new file mode 100644 index 0000000000..3934b8f6d6 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/python/samples/demos/chatkit-integration/frontend/tsconfig.node.json b/python/samples/demos/chatkit-integration/frontend/tsconfig.node.json new file mode 100644 index 0000000000..42872c59f5 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/python/samples/demos/chatkit-integration/frontend/vite.config.ts b/python/samples/demos/chatkit-integration/frontend/vite.config.ts new file mode 100644 index 0000000000..ebf0200e51 --- /dev/null +++ b/python/samples/demos/chatkit-integration/frontend/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; + +const backendTarget = process.env.BACKEND_URL ?? "http://127.0.0.1:8001"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "0.0.0.0", + port: 5171, + proxy: { + "/chatkit": { + target: backendTarget, + changeOrigin: true, + }, + }, + // For production deployments, you need to add your public domains to this list + allowedHosts: [ + // You can remove these examples added just to demonstrate how to configure the allowlist + ".ngrok.io", + ".trycloudflare.com", + ], + }, +}); diff --git a/python/samples/demos/chatkit-integration/store.py b/python/samples/demos/chatkit-integration/store.py new file mode 100644 index 0000000000..17fb746bed --- /dev/null +++ b/python/samples/demos/chatkit-integration/store.py @@ -0,0 +1,361 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""SQLite-based store implementation for ChatKit data persistence. + +This module provides a complete Store implementation using SQLite for data persistence. +It includes proper thread safety, user isolation, and follows the ChatKit Store protocol. +""" + +import sqlite3 +import uuid +from typing import Any + +from chatkit.store import Store, NotFoundError +from chatkit.types import ( + Attachment, + Page, + ThreadItem, + ThreadMetadata, +) +from pydantic import BaseModel + + +class ThreadData(BaseModel): + """Model for serializing thread data to SQLite.""" + thread: ThreadMetadata + + +class ItemData(BaseModel): + """Model for serializing thread item data to SQLite.""" + item: ThreadItem + + +class AttachmentData(BaseModel): + """Model for serializing attachment data to SQLite.""" + attachment: Attachment + + +class SQLiteStore(Store[dict[str, Any]]): + """SQLite-based store implementation for ChatKit data. + + This implementation follows the pattern from the ChatKit Python tests + and provides persistent storage for threads, messages, and attachments. + + Features: + - Thread-safe SQLite connections with WAL mode + - User isolation for multi-tenant support + - Proper error handling and transaction management + - Complete Store protocol implementation + + Note: This is for demonstration purposes. In production, you should + implement proper error handling, connection pooling, and migration strategies. + """ + + def __init__(self, db_path: str | None = None): + self.db_path = db_path or "chatkit_demo.db" # Use file-based DB for demo + self._create_tables() + + def _create_connection(self): + # Enable thread safety and WAL mode for better concurrent access + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + return conn + + def _create_tables(self): + with self._create_connection() as conn: + # Create threads table + conn.execute( + """CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + data TEXT NOT NULL + )""" + ) + + # Create items table + conn.execute( + """CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + data TEXT NOT NULL + )""" + ) + + # Create attachments table + conn.execute( + """CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + data TEXT NOT NULL + )""" + ) + conn.commit() + + def generate_thread_id(self, context: dict[str, Any]) -> str: + return f"thr_{uuid.uuid4().hex[:8]}" + + def generate_item_id( + self, + item_type: str, + thread: ThreadMetadata, + context: dict[str, Any], + ) -> str: + prefix_map = { + "message": "msg", + "tool_call": "tc", + "task": "tsk", + "workflow": "wf", + "attachment": "atc", + } + prefix = prefix_map.get(item_type, "itm") + return f"{prefix}_{uuid.uuid4().hex[:8]}" + + async def load_thread(self, thread_id: str, context: dict[str, Any]) -> ThreadMetadata: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + cursor = conn.execute( + "SELECT data FROM threads WHERE id = ? AND user_id = ?", + (thread_id, user_id), + ).fetchone() + + if cursor is None: + raise NotFoundError(f"Thread {thread_id} not found") + + thread_data = ThreadData.model_validate_json(cursor[0]) + return thread_data.thread + + async def save_thread(self, thread: ThreadMetadata, context: dict[str, Any]) -> None: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + thread_data = ThreadData(thread=thread) + + # Replace existing thread data + conn.execute( + "DELETE FROM threads WHERE id = ? AND user_id = ?", + (thread.id, user_id), + ) + conn.execute( + "INSERT INTO threads (id, user_id, created_at, data) VALUES (?, ?, ?, ?)", + ( + thread.id, + user_id, + thread.created_at.isoformat(), + thread_data.model_dump_json(), + ), + ) + conn.commit() + + async def load_thread_items( + self, + thread_id: str, + after: str | None, + limit: int, + order: str, + context: dict[str, Any], + ) -> Page[ThreadItem]: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + created_after: str | None = None + if after: + after_cursor = conn.execute( + "SELECT created_at FROM items WHERE id = ? AND user_id = ?", + (after, user_id), + ).fetchone() + if after_cursor is None: + raise NotFoundError(f"Item {after} not found") + created_after = after_cursor[0] + + query = """ + SELECT data FROM items + WHERE thread_id = ? AND user_id = ? + """ + params: list[Any] = [thread_id, user_id] + + if created_after: + query += " AND created_at > ?" if order == "asc" else " AND created_at < ?" + params.append(created_after) + + query += f" ORDER BY created_at {order} LIMIT ?" + params.append(limit + 1) + + items_cursor = conn.execute(query, params).fetchall() + items = [ + ItemData.model_validate_json(row[0]).item for row in items_cursor + ] + + has_more = len(items) > limit + if has_more: + items = items[:limit] + + return Page[ThreadItem]( + data=items, + has_more=has_more, + after=items[-1].id if items else None + ) + + async def save_attachment(self, attachment: Attachment, context: dict[str, Any]) -> None: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + attachment_data = AttachmentData(attachment=attachment) + conn.execute( + "INSERT OR REPLACE INTO attachments (id, user_id, data) VALUES (?, ?, ?)", + ( + attachment.id, + user_id, + attachment_data.model_dump_json(), + ), + ) + conn.commit() + + async def load_attachment(self, attachment_id: str, context: dict[str, Any]) -> Attachment: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + cursor = conn.execute( + "SELECT data FROM attachments WHERE id = ? AND user_id = ?", + (attachment_id, user_id), + ).fetchone() + + if cursor is None: + raise NotFoundError(f"Attachment {attachment_id} not found") + + attachment_data = AttachmentData.model_validate_json(cursor[0]) + return attachment_data.attachment + + async def delete_attachment(self, attachment_id: str, context: dict[str, Any]) -> None: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + conn.execute( + "DELETE FROM attachments WHERE id = ? AND user_id = ?", + (attachment_id, user_id), + ) + conn.commit() + + async def load_threads( + self, + limit: int, + after: str | None, + order: str, + context: dict[str, Any], + ) -> Page[ThreadMetadata]: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + created_after: str | None = None + if after: + after_cursor = conn.execute( + "SELECT created_at FROM threads WHERE id = ? AND user_id = ?", + (after, user_id), + ).fetchone() + if after_cursor is None: + raise NotFoundError(f"Thread {after} not found") + created_after = after_cursor[0] + + query = "SELECT data FROM threads WHERE user_id = ?" + params: list[Any] = [user_id] + + if created_after: + query += " AND created_at > ?" if order == "asc" else " AND created_at < ?" + params.append(created_after) + + query += f" ORDER BY created_at {order} LIMIT ?" + params.append(limit + 1) + + threads_cursor = conn.execute(query, params).fetchall() + threads = [ + ThreadData.model_validate_json(row[0]).thread for row in threads_cursor + ] + + has_more = len(threads) > limit + if has_more: + threads = threads[:limit] + + return Page[ThreadMetadata]( + data=threads, + has_more=has_more, + after=threads[-1].id if threads else None + ) + + async def add_thread_item( + self, thread_id: str, item: ThreadItem, context: dict[str, Any] + ) -> None: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + item_data = ItemData(item=item) + conn.execute( + "INSERT INTO items (id, thread_id, user_id, created_at, data) VALUES (?, ?, ?, ?, ?)", + ( + item.id, + thread_id, + user_id, + item.created_at.isoformat(), + item_data.model_dump_json(), + ), + ) + conn.commit() + + async def save_item(self, thread_id: str, item: ThreadItem, context: dict[str, Any]) -> None: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + item_data = ItemData(item=item) + conn.execute( + "UPDATE items SET data = ? WHERE id = ? AND thread_id = ? AND user_id = ?", + ( + item_data.model_dump_json(), + item.id, + thread_id, + user_id, + ), + ) + conn.commit() + + async def load_item(self, thread_id: str, item_id: str, context: dict[str, Any]) -> ThreadItem: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + cursor = conn.execute( + "SELECT data FROM items WHERE id = ? AND thread_id = ? AND user_id = ?", + (item_id, thread_id, user_id), + ).fetchone() + + if cursor is None: + raise NotFoundError(f"Item {item_id} not found in thread {thread_id}") + + item_data = ItemData.model_validate_json(cursor[0]) + return item_data.item + + async def delete_thread(self, thread_id: str, context: dict[str, Any]) -> None: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + conn.execute( + "DELETE FROM threads WHERE id = ? AND user_id = ?", + (thread_id, user_id), + ) + conn.execute( + "DELETE FROM items WHERE thread_id = ? AND user_id = ?", + (thread_id, user_id), + ) + conn.commit() + + async def delete_thread_item( + self, thread_id: str, item_id: str, context: dict[str, Any] + ) -> None: + user_id = context.get("user_id", "demo_user") + + with self._create_connection() as conn: + conn.execute( + "DELETE FROM items WHERE id = ? AND thread_id = ? AND user_id = ?", + (item_id, thread_id, user_id), + ) + conn.commit() diff --git a/python/samples/demos/chatkit-integration/weather_widget.py b/python/samples/demos/chatkit-integration/weather_widget.py new file mode 100644 index 0000000000..834f7a031d --- /dev/null +++ b/python/samples/demos/chatkit-integration/weather_widget.py @@ -0,0 +1,437 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Weather widget rendering for ChatKit integration sample.""" + +import base64 +from dataclasses import dataclass + +from chatkit.actions import ActionConfig +from chatkit.widgets import Box, Button, Card, Col, Image, Row, Text, Title, WidgetRoot + +WEATHER_ICON_COLOR = "#1D4ED8" +WEATHER_ICON_ACCENT = "#DBEAFE" + +# Popular cities for the selector +POPULAR_CITIES = [ + {"value": "seattle", "label": "Seattle, WA", "description": "Pacific Northwest"}, + {"value": "new_york", "label": "New York, NY", "description": "East Coast"}, + {"value": "san_francisco", "label": "San Francisco, CA", "description": "Bay Area"}, + {"value": "chicago", "label": "Chicago, IL", "description": "Midwest"}, + {"value": "miami", "label": "Miami, FL", "description": "Southeast"}, + {"value": "austin", "label": "Austin, TX", "description": "Southwest"}, + {"value": "boston", "label": "Boston, MA", "description": "New England"}, + {"value": "denver", "label": "Denver, CO", "description": "Mountain West"}, + {"value": "portland", "label": "Portland, OR", "description": "Pacific Northwest"}, + {"value": "atlanta", "label": "Atlanta, GA", "description": "Southeast"}, +] + +# Mapping from city values to display names for weather queries +CITY_VALUE_TO_NAME = {city["value"]: city["label"] for city in POPULAR_CITIES} + + + +def _sun_svg() -> str: + """Generate SVG for sunny weather icon.""" + color = WEATHER_ICON_COLOR + accent = WEATHER_ICON_ACCENT + return ( + '' + f'' + f'' + '' + '' + '' + '' + '' + '' + '' + '' + "" + "" + ) + + +def _cloud_svg() -> str: + """Generate SVG for cloudy weather icon.""" + color = WEATHER_ICON_COLOR + accent = WEATHER_ICON_ACCENT + return ( + '' + f'' + "" + ) + + +def _rain_svg() -> str: + """Generate SVG for rainy weather icon.""" + color = WEATHER_ICON_COLOR + accent = WEATHER_ICON_ACCENT + return ( + '' + f'' + f'' + '' + '' + '' + "" + "" + ) + + +def _storm_svg() -> str: + """Generate SVG for stormy weather icon.""" + color = WEATHER_ICON_COLOR + accent = WEATHER_ICON_ACCENT + return ( + '' + f'' + f'' + "" + ) + + +def _snow_svg() -> str: + """Generate SVG for snowy weather icon.""" + color = WEATHER_ICON_COLOR + accent = WEATHER_ICON_ACCENT + return ( + '' + f'' + f'' + '' + '' + '' + '' + '' + '' + "" + "" + ) + + +def _fog_svg() -> str: + """Generate SVG for foggy weather icon.""" + color = WEATHER_ICON_COLOR + accent = WEATHER_ICON_ACCENT + return ( + '' + f'' + f'' + '' + '' + "" + "" + ) + + +def _encode_svg(svg: str) -> str: + """Encode SVG as base64 data URI.""" + encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +# Weather condition to icon mapping +WEATHER_ICONS = { + "sunny": _encode_svg(_sun_svg()), + "cloudy": _encode_svg(_cloud_svg()), + "rainy": _encode_svg(_rain_svg()), + "stormy": _encode_svg(_storm_svg()), + "snowy": _encode_svg(_snow_svg()), + "foggy": _encode_svg(_fog_svg()), +} + +DEFAULT_WEATHER_ICON = _encode_svg(_cloud_svg()) + + +@dataclass +class WeatherData: + """Weather data container.""" + + location: str + condition: str + temperature: int + humidity: int + wind_speed: int + + +def render_weather_widget(data: WeatherData) -> WidgetRoot: + """Render a weather widget from weather data. + + Args: + data: WeatherData containing weather information + + Returns: + A ChatKit WidgetRoot (Card) displaying the weather information + """ + # Get weather icon + weather_icon_src = WEATHER_ICONS.get(data.condition.lower(), DEFAULT_WEATHER_ICON) + + # Build the widget + header = Box( + padding=5, + background="surface-tertiary", + children=[ + Row( + justify="between", + align="center", + children=[ + Col( + align="start", + gap=1, + children=[ + Text( + value=data.location, + size="lg", + weight="semibold", + ), + Text( + value="Current conditions", + color="tertiary", + size="xs", + ), + ], + ), + Box( + padding=3, + radius="full", + background="blue-100", + children=[ + Image( + src=weather_icon_src, + alt=data.condition, + size=28, + fit="contain", + ) + ], + ), + ], + ), + Row( + align="start", + gap=4, + children=[ + Title( + value=f"{data.temperature}°C", + size="lg", + weight="semibold", + ), + Col( + align="start", + gap=1, + children=[ + Text( + value=data.condition.title(), + color="secondary", + size="sm", + weight="medium", + ), + ], + ), + ], + ), + ], + ) + + # Details section + details = Box( + padding=5, + gap=4, + children=[ + Text(value="Weather details", weight="semibold", size="sm"), + Row( + gap=3, + wrap="wrap", + children=[ + _detail_chip("Humidity", f"{data.humidity}%"), + _detail_chip("Wind", f"{data.wind_speed} km/h"), + ], + ), + ], + ) + + return Card( + key="weather", + padding=0, + children=[header, details], + ) + + +def _detail_chip(label: str, value: str) -> Box: + """Create a detail chip widget component.""" + return Box( + padding=3, + radius="xl", + background="surface-tertiary", + width=150, + minWidth=150, + maxWidth=150, + minHeight=80, + maxHeight=80, + flex="0 0 auto", + children=[ + Col( + align="stretch", + gap=2, + children=[ + Text(value=label, size="xs", weight="medium", color="tertiary"), + Row( + justify="center", + margin={"top": 2}, + children=[Text(value=value, weight="semibold", size="lg")], + ), + ], + ) + ], + ) + + +def weather_widget_copy_text(data: WeatherData) -> str: + """Generate plain text representation of weather data. + + Args: + data: WeatherData containing weather information + + Returns: + Plain text description for copy/paste functionality + """ + return ( + f"Weather in {data.location}:\n" + f"• Condition: {data.condition.title()}\n" + f"• Temperature: {data.temperature}°C\n" + f"• Humidity: {data.humidity}%\n" + f"• Wind: {data.wind_speed} km/h" + ) + + +def render_city_selector_widget() -> WidgetRoot: + """Render an interactive city selector widget. + + This widget displays popular cities as a visual selection interface. + Users can click or ask about any city to get weather information. + + Returns: + A ChatKit WidgetRoot (Card) with city selection display + """ + # Create location icon SVG + location_icon = _encode_svg( + '' + f'' + f'' + "" + ) + + # Header section + header = Box( + padding=5, + background="surface-tertiary", + children=[ + Row( + gap=3, + align="center", + children=[ + Box( + padding=3, + radius="full", + background="blue-100", + children=[ + Image( + src=location_icon, + alt="Location", + size=28, + fit="contain", + ) + ], + ), + Col( + align="start", + gap=1, + children=[ + Title( + value="Popular Cities", + size="md", + weight="semibold", + ), + Text( + value="Select a city or ask about any location", + color="tertiary", + size="xs", + ), + ], + ), + ], + ), + ], + ) + + # Create city chips in a grid layout + city_chips: list[Button] = [] + for city in POPULAR_CITIES: + # Create a button that sends an action to query weather for the selected city + chip = Button( + label=city["label"], + variant="outline", + size="md", + onClickAction=ActionConfig( + type="city_selected", + payload={"city_value": city["value"], "city_label": city["label"]}, + handler="server", # Handle on server-side + ), + ) + city_chips.append(chip) + + # Arrange in rows of 3 + city_rows: list[Row] = [] + for i in range(0, len(city_chips), 3): + row_chips: list[Button] = city_chips[i : i + 3] + city_rows.append( + Row( + gap=3, + wrap="wrap", + justify="start", + children=list(row_chips), # Convert to generic list + ) + ) + + # Cities display section + cities_section = Box( + padding=5, + gap=3, + children=[ + *city_rows, + Box( + padding=3, + radius="md", + background="blue-50", + children=[ + Text( + value="💡 Click any city to get its weather, or ask about any other location!", + size="xs", + color="secondary", + ), + ], + ), + ], + ) + + return Card( + key="city_selector", + padding=0, + children=[header, cities_section], + ) + + +def city_selector_copy_text() -> str: + """Generate plain text representation of city selector. + + Returns: + Plain text description for copy/paste functionality + """ + cities_list = "\n".join([f"• {city['label']}" for city in POPULAR_CITIES]) + return f"Popular cities (click to get weather):\n{cities_list}\n\nYou can also ask about weather in any other location!" diff --git a/python/uv.lock b/python/uv.lock index fdcc316836..7e9e6798a7 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -33,6 +33,7 @@ members = [ "agent-framework-a2a", "agent-framework-anthropic", "agent-framework-azure-ai", + "agent-framework-chatkit", "agent-framework-copilotstudio", "agent-framework-core", "agent-framework-devui", @@ -79,6 +80,7 @@ dependencies = [ { name = "agent-framework-a2a", 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", 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-copilotstudio", 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-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -121,6 +123,7 @@ requires-dist = [ { name = "agent-framework-a2a", editable = "packages/a2a" }, { name = "agent-framework-anthropic", editable = "packages/anthropic" }, { name = "agent-framework-azure-ai", editable = "packages/azure-ai" }, + { name = "agent-framework-chatkit", editable = "packages/chatkit" }, { name = "agent-framework-copilotstudio", editable = "packages/copilotstudio" }, { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-devui", editable = "packages/devui" }, @@ -205,6 +208,21 @@ requires-dist = [ { name = "azure-ai-projects", specifier = ">=1.0.0b11" }, ] +[[package]] +name = "agent-framework-chatkit" +version = "1.0.0b251001" +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'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "openai-chatkit", specifier = ">=1.1.0,<2.0.0" }, +] + [[package]] name = "agent-framework-copilotstudio" version = "1.0.0b251104" @@ -2086,6 +2104,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] +[[package]] +name = "griffe" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, +] + [[package]] name = "grpcio" version = "1.76.0" @@ -3497,6 +3527,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" }, ] +[[package]] +name = "openai-agents" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { 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 = "types-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'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/37/2b4f828840d3ff32d82b813c3371ec9ee26b3b8dc6b4acbb7a4a579f617a/openai_agents-0.3.3.tar.gz", hash = "sha256:b016381a6890e1cb6879eb23c53c35f8c2312be1117f1cd4e4b5e2463150839f", size = 1816230, upload-time = "2025-09-30T23:20:24.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/59/fd49fd2c3184c0d5fedb8c9c456ae9852154828bca7ee69dce004ea83188/openai_agents-0.3.3-py3-none-any.whl", hash = "sha256:aa2c74e010b923c09f166e63a51fae8c850c62df8581b84bafcbe5bd208d1505", size = 210893, upload-time = "2025-09-30T23:20:22.037Z" }, +] + +[[package]] +name = "openai-chatkit" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/19/9948f2996c224aff01f6ef415784042c3d710c1e950937b16d9a2c07e47e/openai_chatkit-1.1.0.tar.gz", hash = "sha256:5594341aab29b56fd3396e8d3ad1962ebdb8c44f062a8e315663ac8cf1371c6b", size = 49480, upload-time = "2025-11-03T22:50:05.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/82/07db74ee63d54f3cadab3baaa1534bef0d3699a94d2618c76050cccb0cfe/openai_chatkit-1.1.0-py3-none-any.whl", hash = "sha256:e78f021899fbef1323f3adc3a686f9fe5ee184cd997799a917e9013833e760ba", size = 35424, upload-time = "2025-11-03T22:50:03.788Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.38.0" @@ -5919,6 +5982,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" }, ] +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 35a8565495d0df5b876a96e1ded1ccbb340641ff Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:25:24 +0900 Subject: [PATCH 09/16] Python: AG-UI protocol support (#1826) * Add AG-UI integration * Fix tests. PR feedback * Cleanup * PR Feedback * Improve README and getting started experience * Fix links --- python/.vscode/launch.json | 9 + python/packages/ag-ui/LICENSE | 21 + python/packages/ag-ui/README.md | 71 ++ .../ag-ui/agent_framework_ag_ui/__init__.py | 31 + .../ag-ui/agent_framework_ag_ui/_agent.py | 160 ++++ .../_confirmation_strategies.py | 175 +++++ .../ag-ui/agent_framework_ag_ui/_endpoint.py | 94 +++ .../ag-ui/agent_framework_ag_ui/_events.py | 675 +++++++++++++++++ .../_message_adapters.py | 218 ++++++ .../agent_framework_ag_ui/_orchestrators.py | 439 +++++++++++ .../ag-ui/agent_framework_ag_ui/_types.py | 27 + .../ag-ui/agent_framework_ag_ui/_utils.py | 57 ++ .../ag-ui/agent_framework_ag_ui/py.typed | 1 + python/packages/ag-ui/examples/.env.example | 3 + .../ag-ui/examples/.vscode/settings.json | 5 + python/packages/ag-ui/examples/README.md | 243 ++++++ python/packages/ag-ui/examples/__init__.py | 1 + python/packages/ag-ui/examples/__main__.py | 8 + .../ag-ui/examples/agents/__init__.py | 3 + .../examples/agents/document_writer_agent.py | 58 ++ .../agents/human_in_the_loop_agent.py | 76 ++ .../ag-ui/examples/agents/recipe_agent.py | 122 +++ .../agents/research_assistant_agent.py | 100 +++ .../ag-ui/examples/agents/simple_agent.py | 13 + .../examples/agents/task_planner_agent.py | 73 ++ .../ag-ui/examples/agents/task_steps_agent.py | 318 ++++++++ .../examples/agents/ui_generator_agent.py | 119 +++ .../ag-ui/examples/agents/weather_agent.py | 71 ++ .../ag-ui/examples/server/__init__.py | 1 + .../ag-ui/examples/server/api/__init__.py | 3 + .../server/api/backend_tool_rendering.py | 22 + python/packages/ag-ui/examples/server/main.py | 129 ++++ .../packages/ag-ui/getting_started/README.md | 705 ++++++++++++++++++ .../packages/ag-ui/getting_started/client.py | 122 +++ .../packages/ag-ui/getting_started/server.py | 44 ++ python/packages/ag-ui/pyproject.toml | 61 ++ python/packages/ag-ui/tests/__init__.py | 1 + .../tests/test_agent_wrapper_comprehensive.py | 577 ++++++++++++++ .../tests/test_backend_tool_rendering.py | 124 +++ ...t_confirmation_strategies_comprehensive.py | 275 +++++++ .../ag-ui/tests/test_document_writer_flow.py | 243 ++++++ python/packages/ag-ui/tests/test_endpoint.py | 242 ++++++ .../ag-ui/tests/test_events_comprehensive.py | 659 ++++++++++++++++ .../ag-ui/tests/test_human_in_the_loop.py | 96 +++ .../ag-ui/tests/test_message_adapters.py | 249 +++++++ .../packages/ag-ui/tests/test_shared_state.py | 109 +++ .../ag-ui/tests/test_structured_output.py | 257 +++++++ python/packages/ag-ui/tests/test_types.py | 145 ++++ python/packages/ag-ui/tests/test_utils.py | 199 +++++ python/pyproject.toml | 3 + python/uv.lock | 383 ++++++---- 51 files changed, 7677 insertions(+), 163 deletions(-) create mode 100644 python/packages/ag-ui/LICENSE create mode 100644 python/packages/ag-ui/README.md create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/__init__.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_agent.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_confirmation_strategies.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_events.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_types.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_utils.py create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/py.typed create mode 100644 python/packages/ag-ui/examples/.env.example create mode 100644 python/packages/ag-ui/examples/.vscode/settings.json create mode 100644 python/packages/ag-ui/examples/README.md create mode 100644 python/packages/ag-ui/examples/__init__.py create mode 100644 python/packages/ag-ui/examples/__main__.py create mode 100644 python/packages/ag-ui/examples/agents/__init__.py create mode 100644 python/packages/ag-ui/examples/agents/document_writer_agent.py create mode 100644 python/packages/ag-ui/examples/agents/human_in_the_loop_agent.py create mode 100644 python/packages/ag-ui/examples/agents/recipe_agent.py create mode 100644 python/packages/ag-ui/examples/agents/research_assistant_agent.py create mode 100644 python/packages/ag-ui/examples/agents/simple_agent.py create mode 100644 python/packages/ag-ui/examples/agents/task_planner_agent.py create mode 100644 python/packages/ag-ui/examples/agents/task_steps_agent.py create mode 100644 python/packages/ag-ui/examples/agents/ui_generator_agent.py create mode 100644 python/packages/ag-ui/examples/agents/weather_agent.py create mode 100644 python/packages/ag-ui/examples/server/__init__.py create mode 100644 python/packages/ag-ui/examples/server/api/__init__.py create mode 100644 python/packages/ag-ui/examples/server/api/backend_tool_rendering.py create mode 100644 python/packages/ag-ui/examples/server/main.py create mode 100644 python/packages/ag-ui/getting_started/README.md create mode 100644 python/packages/ag-ui/getting_started/client.py create mode 100644 python/packages/ag-ui/getting_started/server.py create mode 100644 python/packages/ag-ui/pyproject.toml create mode 100644 python/packages/ag-ui/tests/__init__.py create mode 100644 python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py create mode 100644 python/packages/ag-ui/tests/test_backend_tool_rendering.py create mode 100644 python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py create mode 100644 python/packages/ag-ui/tests/test_document_writer_flow.py create mode 100644 python/packages/ag-ui/tests/test_endpoint.py create mode 100644 python/packages/ag-ui/tests/test_events_comprehensive.py create mode 100644 python/packages/ag-ui/tests/test_human_in_the_loop.py create mode 100644 python/packages/ag-ui/tests/test_message_adapters.py create mode 100644 python/packages/ag-ui/tests/test_shared_state.py create mode 100644 python/packages/ag-ui/tests/test_structured_output.py create mode 100644 python/packages/ag-ui/tests/test_types.py create mode 100644 python/packages/ag-ui/tests/test_utils.py diff --git a/python/.vscode/launch.json b/python/.vscode/launch.json index b0ab97127e..4c6c3c0b01 100644 --- a/python/.vscode/launch.json +++ b/python/.vscode/launch.json @@ -12,6 +12,15 @@ "console": "integratedTerminal", "justMyCode": false }, + { + "name": "AG-UI Examples Server", + "type": "debugpy", + "request": "launch", + "module": "examples", + "cwd": "${workspaceFolder}/packages/ag-ui", + "console": "integratedTerminal", + "justMyCode": false + }, { "name": "Python Attach", "type": "debugpy", diff --git a/python/packages/ag-ui/LICENSE b/python/packages/ag-ui/LICENSE new file mode 100644 index 0000000000..22aed37e65 --- /dev/null +++ b/python/packages/ag-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md new file mode 100644 index 0000000000..7e0d6b73d9 --- /dev/null +++ b/python/packages/ag-ui/README.md @@ -0,0 +1,71 @@ +# Agent Framework AG-UI Integration + +AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol. + +## Installation + +```bash +pip install agent-framework-ag-ui +``` + +## Quick Start + +```python +from fastapi import FastAPI +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +# Create your agent +agent = ChatAgent( + name="my_agent", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint="https://your-resource.openai.azure.com/", + deployment_name="gpt-4o-mini", + ), +) + +# Create FastAPI app and add AG-UI endpoint +app = FastAPI() +add_agent_framework_fastapi_endpoint(app, agent, "/") + +# Run with: uvicorn main:app --reload +``` + +## Documentation + +- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building your first AG-UI server and client +- **[Examples](examples/)** - Complete examples for AG-UI features + +## Features + +This integration supports all 7 AG-UI features: + +1. **Agentic Chat**: Basic streaming chat with tool calling support +2. **Backend Tool Rendering**: Tools executed on backend with results streamed to client +3. **Human in the Loop**: Function approval requests for user confirmation before tool execution +4. **Agentic Generative UI**: Async tools for long-running operations with progress updates +5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls +6. **Shared State**: Bidirectional state sync between client and server +7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution + +## Architecture + +The package uses a clean, orchestrator-based architecture: + +- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators +- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.) +- **Confirmation Strategies**: Domain-specific confirmation messages (extensible) +- **AgentFrameworkEventBridge**: Converts Agent Framework events to AG-UI events +- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats +- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE) + +## Next Steps + +1. **New to AG-UI?** Start with the [Getting Started Tutorial](getting_started/) +2. **Want to see examples?** Check out the [Examples](examples/) for AG-UI features + +## License + +MIT diff --git a/python/packages/ag-ui/agent_framework_ag_ui/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py new file mode 100644 index 0000000000..1adedb2649 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI protocol integration for Agent Framework.""" + +import importlib.metadata + +from ._agent import AgentFrameworkAgent +from ._confirmation_strategies import ( + ConfirmationStrategy, + DefaultConfirmationStrategy, + DocumentWriterConfirmationStrategy, + RecipeConfirmationStrategy, + TaskPlannerConfirmationStrategy, +) +from ._endpoint import add_agent_framework_fastapi_endpoint + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "AgentFrameworkAgent", + "add_agent_framework_fastapi_endpoint", + "ConfirmationStrategy", + "DefaultConfirmationStrategy", + "TaskPlannerConfirmationStrategy", + "RecipeConfirmationStrategy", + "DocumentWriterConfirmationStrategy", + "__version__", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py new file mode 100644 index 0000000000..298c0acfe9 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py @@ -0,0 +1,160 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AgentFrameworkAgent wrapper for AG-UI protocol - Clean Architecture.""" + +from collections.abc import AsyncGenerator +from typing import Any + +from ag_ui.core import BaseEvent +from agent_framework import AgentProtocol + +from ._confirmation_strategies import ConfirmationStrategy, DefaultConfirmationStrategy +from ._orchestrators import ( + DefaultOrchestrator, + ExecutionContext, + HumanInTheLoopOrchestrator, + Orchestrator, +) + + +class AgentConfig: + """Configuration for agent wrapper.""" + + def __init__( + self, + state_schema: dict[str, Any] | None = None, + predict_state_config: dict[str, dict[str, str]] | None = None, + require_confirmation: bool = True, + ): + """Initialize agent configuration. + + Args: + state_schema: Optional state schema for state management + predict_state_config: Configuration for predictive state updates + require_confirmation: Whether predictive updates require confirmation + """ + self.state_schema = state_schema or {} + self.predict_state_config = predict_state_config or {} + self.require_confirmation = require_confirmation + + +class AgentFrameworkAgent: + """Wraps Agent Framework agents for AG-UI protocol compatibility. + + Translates between Agent Framework's AgentProtocol and AG-UI's event-based + protocol. Uses orchestrators to handle different execution flows (standard + execution, human-in-the-loop, etc.). Orchestrators are checked in order; + the first matching orchestrator handles the request. + + Supports predictive state updates for agentic generative UI, with optional + confirmation requirements configurable per use case. + """ + + def __init__( + self, + agent: AgentProtocol, + name: str | None = None, + description: str | None = None, + state_schema: dict[str, Any] | None = None, + predict_state_config: dict[str, dict[str, str]] | None = None, + require_confirmation: bool = True, + orchestrators: list[Orchestrator] | None = None, + confirmation_strategy: ConfirmationStrategy | None = None, + ): + """Initialize the AG-UI compatible agent wrapper. + + Args: + agent: The Agent Framework agent to wrap + name: Optional name for the agent + description: Optional description + state_schema: Optional state schema for state management + predict_state_config: Configuration for predictive state updates. + Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}} + require_confirmation: Whether predictive updates require confirmation. + Set to False for agentic generative UI that updates automatically. + orchestrators: Custom orchestrators (auto-configured if None). + Orchestrators are checked in order; first match handles the request. + confirmation_strategy: Strategy for generating confirmation messages. + Defaults to DefaultConfirmationStrategy if None. + """ + self.agent = agent + self.name = name or getattr(agent, "name", "agent") + self.description = description or getattr(agent, "description", "") + + self.config = AgentConfig( + state_schema=state_schema, + predict_state_config=predict_state_config, + require_confirmation=require_confirmation, + ) + + # Configure orchestrators + if orchestrators is None: + self.orchestrators = self._default_orchestrators() + else: + self.orchestrators = orchestrators + + # Configure confirmation strategy + if confirmation_strategy is None: + self.confirmation_strategy: ConfirmationStrategy = DefaultConfirmationStrategy() + else: + self.confirmation_strategy = confirmation_strategy + + def _default_orchestrators(self) -> list[Orchestrator]: + """Create default orchestrator chain. + + Returns: + List of orchestrators in priority order. First matching orchestrator + handles the request, so order matters. + """ + return [ + HumanInTheLoopOrchestrator(), # Handle tool approval responses + # Add more specialized orchestrators here as needed + DefaultOrchestrator(), # Fallback: standard agent execution + ] + + async def run_agent( + self, + input_data: dict[str, Any], + ) -> AsyncGenerator[BaseEvent, None]: + """Run the agent and yield AG-UI events. + + This is the ONLY public method - much simpler than the original 376-line + implementation. All orchestration logic has been extracted into dedicated + Orchestrator classes. + + The method creates an ExecutionContext with all needed data, then finds + the first orchestrator that can handle the request and delegates to it. + + Args: + input_data: The AG-UI run input containing messages, state, etc. + + Yields: + AG-UI events + + Raises: + RuntimeError: If no orchestrator matches (should never happen if + DefaultOrchestrator is last in the chain) + """ + # Create execution context with all needed data + context = ExecutionContext( + input_data=input_data, + agent=self.agent, + config=self.config, + confirmation_strategy=self.confirmation_strategy, + ) + + # Find matching orchestrator and execute + for orchestrator in self.orchestrators: + if orchestrator.can_handle(context): + async for event in orchestrator.run(context): + yield event + return + + # Should never reach here if DefaultOrchestrator is last + raise RuntimeError("No orchestrator matched - check configuration") + + +__all__ = [ + "AgentFrameworkAgent", + "AgentConfig", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_confirmation_strategies.py b/python/packages/ag-ui/agent_framework_ag_ui/_confirmation_strategies.py new file mode 100644 index 0000000000..8bba842705 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_confirmation_strategies.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Confirmation strategies for human-in-the-loop approval flows. + +Each agent can provide a custom confirmation strategy to generate domain-specific +messages when users approve or reject changes/actions. +""" + +from abc import ABC, abstractmethod +from typing import Any + + +class ConfirmationStrategy(ABC): + """Strategy for generating confirmation messages during human-in-the-loop flows.""" + + @abstractmethod + def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: + """Generate message when user approves function execution. + + Args: + steps: List of approved steps with 'description', 'status', etc. + + Returns: + Message to display to user + """ + ... + + @abstractmethod + def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str: + """Generate message when user rejects function execution. + + Args: + steps: List of rejected steps + + Returns: + Message to display to user + """ + ... + + @abstractmethod + def on_state_confirmed(self) -> str: + """Generate message when user confirms predictive state changes. + + Returns: + Message to display to user + """ + ... + + @abstractmethod + def on_state_rejected(self) -> str: + """Generate message when user rejects predictive state changes. + + Returns: + Message to display to user + """ + ... + + +class DefaultConfirmationStrategy(ConfirmationStrategy): + """Generic confirmation messages suitable for most agents. + + This preserves the original behavior from v1. + """ + + def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: + """Generate generic approval message with step list.""" + enabled_steps = [s for s in steps if s.get("status") == "enabled"] + + message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"] + + for i, step in enumerate(enabled_steps, 1): + message_parts.append(f"{i}. {step['description']}\n") + + message_parts.append("\nAll steps completed successfully!") + + return "".join(message_parts) + + def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str: + """Generate generic rejection message.""" + return "No problem! What would you like me to change about the plan?" + + def on_state_confirmed(self) -> str: + """Generate generic state confirmation message.""" + return "Changes confirmed and applied successfully!" + + def on_state_rejected(self) -> str: + """Generate generic state rejection message.""" + return "No problem! What would you like me to change?" + + +class TaskPlannerConfirmationStrategy(ConfirmationStrategy): + """Domain-specific confirmation messages for task planning agents.""" + + def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: + """Generate task-specific approval message.""" + enabled_steps = [s for s in steps if s.get("status") == "enabled"] + + message_parts = ["Executing your requested tasks:\n\n"] + + for i, step in enumerate(enabled_steps, 1): + message_parts.append(f"{i}. {step['description']}\n") + + message_parts.append("\nAll tasks completed successfully!") + + return "".join(message_parts) + + def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str: + """Generate task-specific rejection message.""" + return "No problem! Let me revise the plan. What would you like me to change?" + + def on_state_confirmed(self) -> str: + """Task planners typically don't use state confirmation.""" + return "Tasks confirmed and ready to execute!" + + def on_state_rejected(self) -> str: + """Task planners typically don't use state confirmation.""" + return "No problem! How should I adjust the task list?" + + +class RecipeConfirmationStrategy(ConfirmationStrategy): + """Domain-specific confirmation messages for recipe agents.""" + + def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: + """Generate recipe-specific approval message.""" + enabled_steps = [s for s in steps if s.get("status") == "enabled"] + + message_parts = ["Updating your recipe:\n\n"] + + for i, step in enumerate(enabled_steps, 1): + message_parts.append(f"{i}. {step['description']}\n") + + message_parts.append("\nRecipe updated successfully!") + + return "".join(message_parts) + + def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str: + """Generate recipe-specific rejection message.""" + return "No problem! What ingredients or steps should I change?" + + def on_state_confirmed(self) -> str: + """Generate recipe-specific state confirmation message.""" + return "Recipe changes applied successfully!" + + def on_state_rejected(self) -> str: + """Generate recipe-specific state rejection message.""" + return "No problem! What would you like me to adjust in the recipe?" + + +class DocumentWriterConfirmationStrategy(ConfirmationStrategy): + """Domain-specific confirmation messages for document writing agents.""" + + def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: + """Generate document-specific approval message.""" + enabled_steps = [s for s in steps if s.get("status") == "enabled"] + + message_parts = ["Applying your edits:\n\n"] + + for i, step in enumerate(enabled_steps, 1): + message_parts.append(f"{i}. {step['description']}\n") + + message_parts.append("\nDocument updated successfully!") + + return "".join(message_parts) + + def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str: + """Generate document-specific rejection message.""" + return "No problem! Which changes should I keep or modify?" + + def on_state_confirmed(self) -> str: + """Generate document-specific state confirmation message.""" + return "Document edits applied!" + + def on_state_rejected(self) -> str: + """Generate document-specific state rejection message.""" + return "No problem! What should I change about the document?" diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py b/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py new file mode 100644 index 0000000000..ba6e9f5ddd --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""FastAPI endpoint creation for AG-UI agents.""" + +import logging +from typing import Any + +from ag_ui.encoder import EventEncoder +from agent_framework import AgentProtocol +from fastapi import FastAPI, Request +from fastapi.responses import StreamingResponse + +from ._agent import AgentFrameworkAgent + +logger = logging.getLogger(__name__) + + +def add_agent_framework_fastapi_endpoint( + app: FastAPI, + agent: AgentProtocol | AgentFrameworkAgent, + path: str = "/", + state_schema: dict[str, Any] | None = None, + predict_state_config: dict[str, dict[str, str]] | None = None, + allow_origins: list[str] | None = None, +) -> None: + """Add an AG-UI endpoint to a FastAPI app. + + Args: + app: The FastAPI application + agent: The agent to expose (can be raw AgentProtocol or wrapped) + path: The endpoint path + state_schema: Optional state schema for shared state management + predict_state_config: Optional predictive state update configuration. + Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}} + allow_origins: CORS origins (not yet implemented) + """ + if isinstance(agent, AgentProtocol): + wrapped_agent = AgentFrameworkAgent( + agent=agent, + state_schema=state_schema, + predict_state_config=predict_state_config, + ) + else: + wrapped_agent = agent + + @app.post(path) + async def agent_endpoint(request: Request): # type: ignore[misc] + """Handle AG-UI agent requests. + + Note: Function is accessed via FastAPI's decorator registration, + despite appearing unused to static analysis. + """ + try: + input_data = await request.json() + logger.debug( + f"[{path}] Received request - Run ID: {input_data.get('run_id', 'no-run-id')}, " + f"Thread ID: {input_data.get('thread_id', 'no-thread-id')}, " + f"Messages: {len(input_data.get('messages', []))}" + ) + logger.info(f"Received request at {path}: {input_data.get('run_id', 'no-run-id')}") + + async def event_generator(): + encoder = EventEncoder() + event_count = 0 + async for event in wrapped_agent.run_agent(input_data): + event_count += 1 + logger.debug(f"[{path}] Event {event_count}: {type(event).__name__}") + + # Log event payload for debugging + if hasattr(event, "model_dump"): + event_data = event.model_dump(exclude_none=True) + logger.debug(f"[{path}] Event payload: {event_data}") + + encoded = encoder.encode(event) + logger.debug( + f"[{path}] Encoded as: {encoded[:200]}..." + if len(encoded) > 200 + else f"[{path}] Encoded as: {encoded}" + ) + yield encoded + logger.info(f"[{path}] Completed streaming {event_count} events") + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + except Exception as e: + logger.error(f"Error in agent endpoint: {e}", exc_info=True) + return {"error": str(e)} diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py new file mode 100644 index 0000000000..b6b2294d45 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -0,0 +1,675 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Event bridge for converting Agent Framework events to AG-UI protocol.""" + +import json +import logging +import re +from typing import Any + +from ag_ui.core import ( + BaseEvent, + CustomEvent, + EventType, + MessagesSnapshotEvent, + RunFinishedEvent, + RunStartedEvent, + StateDeltaEvent, + StateSnapshotEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from agent_framework import ( + AgentRunResponseUpdate, + FunctionApprovalRequestContent, + FunctionCallContent, + FunctionResultContent, + TextContent, +) + +from ._utils import generate_event_id + +logger = logging.getLogger(__name__) + + +class AgentFrameworkEventBridge: + """Converts Agent Framework responses to AG-UI events.""" + + def __init__( + self, + run_id: str, + thread_id: str, + predict_state_config: dict[str, dict[str, str]] | None = None, + current_state: dict[str, Any] | None = None, + skip_text_content: bool = False, + input_messages: list[Any] | None = None, + require_confirmation: bool = True, + ) -> None: + """ + Initialize the event bridge. + + Args: + run_id: The run identifier. + thread_id: The thread identifier. + predict_state_config: Configuration for predictive state updates. + Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}} + current_state: Reference to the current state dict for tracking updates. + skip_text_content: If True, skip emitting TextMessageContentEvents (for structured outputs). + input_messages: The input messages from the conversation history. + require_confirmation: Whether predictive state updates require user confirmation. + """ + self.run_id = run_id + self.thread_id = thread_id + self.current_message_id: str | None = None + self.current_tool_call_id: str | None = None + self.current_tool_call_name: str | None = None # Track the tool name across streaming chunks + self.predict_state_config = predict_state_config or {} + self.current_state = current_state or {} + self.pending_state_updates: dict[str, Any] = {} # Track updates from tool calls + self.skip_text_content = skip_text_content + self.require_confirmation = require_confirmation + + # For predictive state updates: accumulate streaming arguments + self.streaming_tool_args: str = "" # Accumulated JSON string + self.last_emitted_state: dict[str, Any] = {} # Track last emitted state to avoid duplicates + self.state_delta_count: int = 0 # Counter for sampling log output + self.should_stop_after_confirm: bool = False # Flag to stop run after confirm_changes + self.suppressed_summary: str = "" # Store LLM summary to show after confirmation + + # For MessagesSnapshotEvent: track tool calls and results + self.input_messages = input_messages or [] + self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message + self.tool_results: list[dict[str, Any]] = [] # Track tool results + + async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]: + """ + Convert an AgentRunResponseUpdate to AG-UI events. + + Args: + update: The agent run update to convert. + + Returns: + List of AG-UI events. + """ + events: list[BaseEvent] = [] + + for content in update.contents: + if isinstance(content, TextContent): + # Skip text content if using structured outputs (it's just the JSON) + if self.skip_text_content: + continue + + # Skip text content if we're about to emit confirm_changes + # The summary should only appear after user confirms + if self.should_stop_after_confirm: + logger.debug(" >>> Skipping text content - waiting for confirm_changes response") + # Save the summary text to show after confirmation + self.suppressed_summary += content.text + continue + + if not self.current_message_id: + self.current_message_id = generate_event_id() + start_event = TextMessageStartEvent( + message_id=self.current_message_id, + role="assistant", + ) + events.append(start_event) + + event = TextMessageContentEvent( + message_id=self.current_message_id, + delta=content.text, + ) + events.append(event) + + elif isinstance(content, FunctionCallContent): + # Log tool calls for debugging + if content.name: + logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})") + + if not content.name and not content.call_id and not self.current_tool_call_name: + args_preview = str(content.arguments)[:50] if content.arguments else "None" + logger.warning(f"FunctionCallContent missing name and call_id. Args: {args_preview}") + + # Get or use existing tool call ID - all chunks of same tool call share the same call_id + # Important: the first chunk might have name but no call_id yet + if content.call_id: + tool_call_id = content.call_id + elif self.current_tool_call_id: + tool_call_id = self.current_tool_call_id + else: + # Generate a new ID for this tool call + tool_call_id = ( + generate_event_id() + ) # Handle streaming tool calls - name comes in first chunk, arguments in subsequent chunks + if content.name: + # This is a new tool call or the first chunk with the name + self.current_tool_call_id = tool_call_id + self.current_tool_call_name = content.name + + tool_start_event = ToolCallStartEvent( + tool_call_id=tool_call_id, + tool_call_name=content.name, + parent_message_id=self.current_message_id, + ) + logger.info(f" >>> Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'") + events.append(tool_start_event) + + # Track tool call for MessagesSnapshotEvent + # Initialize a new tool call entry + self.pending_tool_calls.append( + { + "id": tool_call_id, + "type": "function", + "function": { + "name": content.name, + "arguments": "", # Will accumulate as we get argument chunks + }, + } + ) + else: + # Subsequent chunk without name - update our tracked ID if needed + if tool_call_id: + self.current_tool_call_id = tool_call_id + + # Emit arguments if present + if content.arguments: + # content.arguments is already a JSON string from the LLM for streaming calls + # For non-streaming it could be a dict, so we need to handle both + if isinstance(content.arguments, str): + delta_str = content.arguments + else: + # If it's a dict, convert to JSON + delta_str = json.dumps(content.arguments) + + logger.info(f" >>> Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'") + args_event = ToolCallArgsEvent( + tool_call_id=tool_call_id, + delta=delta_str, + ) + events.append(args_event) + + # Accumulate arguments for MessagesSnapshotEvent + if self.pending_tool_calls: + # Find the matching tool call and append the delta + for tool_call in self.pending_tool_calls: + if tool_call["id"] == tool_call_id: + tool_call["function"]["arguments"] += delta_str + break + + # Predictive state updates - accumulate streaming arguments and emit deltas + # Use current_tool_call_name since content.name is only present on first chunk + if self.current_tool_call_name and self.predict_state_config: + # Accumulate the argument string + if isinstance(content.arguments, str): + self.streaming_tool_args += content.arguments + else: + self.streaming_tool_args += json.dumps(content.arguments) + + logger.debug( + f" >>> Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'" + ) + + # Try to parse accumulated arguments (may be incomplete JSON) + # We use a lenient approach: try standard parsing first, then try to extract partial values + parsed_args = None + try: + parsed_args = json.loads(self.streaming_tool_args) + except json.JSONDecodeError: + # JSON is incomplete - try to extract partial string values + # For streaming "document" field, we can extract: {"document": "text... + # Look for pattern: {"field": "value (incomplete) + for state_key, config in self.predict_state_config.items(): + if config["tool"] == self.current_tool_call_name: + tool_arg_name = config["tool_argument"] + + # Try to extract partial string value for this argument + # Pattern: "argument_name": "partial text + pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)' + match = re.search(pattern, self.streaming_tool_args) + + if match: + partial_value = match.group(1) + # Unescape common sequences + partial_value = ( + partial_value.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\") + ) + + # Emit delta if we have new content + if ( + state_key not in self.last_emitted_state + or self.last_emitted_state[state_key] != partial_value + ): + state_delta_event = StateDeltaEvent( + delta=[ + { + "op": "replace", + "path": f"/{state_key}", + "value": partial_value, + } + ], + ) + + self.state_delta_count += 1 + if self.state_delta_count % 10 == 1: + value_preview = ( + str(partial_value)[:100] + "..." + if len(str(partial_value)) > 100 + else str(partial_value) + ) + logger.info( + f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': " + f"op=replace, path=/{state_key}, value={value_preview}" + ) + elif self.state_delta_count % 100 == 0: + logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted") + + events.append(state_delta_event) + self.last_emitted_state[state_key] = partial_value + self.pending_state_updates[state_key] = partial_value + + # If we successfully parsed complete JSON, process it + if parsed_args: + # Check if this tool matches any predictive state config + for state_key, config in self.predict_state_config.items(): + if config["tool"] == self.current_tool_call_name: + tool_arg_name = config["tool_argument"] + + # Extract the state value + if tool_arg_name == "*": + state_value = parsed_args + elif tool_arg_name in parsed_args: + state_value = parsed_args[tool_arg_name] + else: + continue + + # Only emit if state has changed from last emission + if ( + state_key not in self.last_emitted_state + or self.last_emitted_state[state_key] != state_value + ): + # Emit StateDeltaEvent for real-time UI updates (JSON Patch format) + state_delta_event = StateDeltaEvent( + delta=[ + { + "op": "replace", # Use replace since field exists in schema + "path": f"/{state_key}", # JSON Pointer path with leading slash + "value": state_value, + } + ], + ) + + # Increment counter and log every 10th emission with sample data + self.state_delta_count += 1 + if self.state_delta_count % 10 == 1: # Log 1st, 11th, 21st, etc. + value_preview = ( + str(state_value)[:100] + "..." + if len(str(state_value)) > 100 + else str(state_value) + ) + logger.info( + f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': " + f"op=replace, path=/{state_key}, value={value_preview}" + ) + elif self.state_delta_count % 100 == 0: # Also log every 100th + logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted") + + events.append(state_delta_event) + + # Track what we emitted + self.last_emitted_state[state_key] = state_value + self.pending_state_updates[state_key] = state_value + + # Legacy predictive state check (for when arguments are complete) + if content.name and content.arguments: + parsed_args = content.parse_arguments() + + if parsed_args: + logger.info(f"Checking predict_state_config: {self.predict_state_config}") + for state_key, config in self.predict_state_config.items(): + logger.info(f"Checking state_key='{state_key}', config={config}") + if config["tool"] == content.name: + tool_arg_name = config["tool_argument"] + logger.info( + f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'" + ) + + # If tool_argument is "*", use all arguments as the state value + if tool_arg_name == "*": + state_value = parsed_args + logger.info(f"Using all args as state value, keys: {list(state_value.keys())}") + elif tool_arg_name in parsed_args: + state_value = parsed_args[tool_arg_name] + logger.info(f"Using specific arg '{tool_arg_name}' as state value") + else: + logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args") + continue + + # Emit predictive delta (JSON Patch format) + state_delta_event = StateDeltaEvent( + delta=[ + { + "op": "replace", # Use replace since field exists in schema + "path": f"/{state_key}", # JSON Pointer path with leading slash + "value": state_value, + } + ], + ) + logger.info( + f" >>> Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}" + ) + events.append(state_delta_event) + + # Track pending update for later snapshot + self.pending_state_updates[state_key] = state_value + + # Note: ToolCallEndEvent is emitted when we receive FunctionResultContent, + # not here during streaming, since we don't know when the stream is complete + + elif isinstance(content, FunctionResultContent): + # First emit ToolCallEndEvent to close the tool call + if content.call_id: + end_event = ToolCallEndEvent( + tool_call_id=content.call_id, + ) + logger.info(f" >>> Emitting ToolCallEndEvent for completed tool call '{content.call_id}'") + events.append(end_event) + + # Log total StateDeltaEvent count for this tool call + if self.state_delta_count > 0: + logger.info( + f" >>> Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total" + ) + + # Reset streaming accumulator and counter for next tool call + self.streaming_tool_args = "" + self.state_delta_count = 0 + + # Tool result - emit ToolCallResultEvent + result_message_id = generate_event_id() + + # Preserve structured data for backend tool rendering + # Serialize dicts to JSON string, otherwise convert to string + if isinstance(content.result, dict): + result_content = json.dumps(content.result) # type: ignore[arg-type] + elif content.result is not None: + result_content = str(content.result) + else: + result_content = "" + + result_event = ToolCallResultEvent( + message_id=result_message_id, + tool_call_id=content.call_id, + content=result_content, + role="tool", + ) + events.append(result_event) + + # Track tool result for MessagesSnapshotEvent + self.tool_results.append( + { + "id": result_message_id, + "role": "tool", + "tool_call_id": content.call_id, + "content": result_content, + } + ) + + # Emit MessagesSnapshotEvent with the complete conversation including tool calls and results + # This is required for CopilotKit's useCopilotAction to detect tool result + if self.pending_tool_calls and self.tool_results: + # Build assistant message with tool_calls + assistant_message = { + "id": generate_event_id(), + "role": "assistant", + "tool_calls": self.pending_tool_calls.copy(), # Copy the accumulated tool calls + } + + # Build complete messages array: input messages + assistant message + tool results + all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy() + + # Emit MessagesSnapshotEvent using the proper event type + messages_snapshot_event = MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, messages=all_messages + ) + logger.info(f" >>> Emitting MessagesSnapshotEvent with {len(all_messages)} messages") + events.append(messages_snapshot_event) + + # After tool execution, emit StateSnapshotEvent if we have pending state updates + if self.pending_state_updates: + # Update the current state with pending updates + for key, value in self.pending_state_updates.items(): + self.current_state[key] = value + + # Log the state structure for debugging + logger.info(f"Emitting StateSnapshotEvent with keys: {list(self.current_state.keys())}") + if "recipe" in self.current_state: + recipe = self.current_state["recipe"] + logger.info( + f"Recipe fields: title={recipe.get('title')}, " + f"skill_level={recipe.get('skill_level')}, " + f"ingredients_count={len(recipe.get('ingredients', []))}, " + f"instructions_count={len(recipe.get('instructions', []))}" + ) + + # Emit complete state snapshot + state_snapshot_event = StateSnapshotEvent( + snapshot=self.current_state, + ) + events.append(state_snapshot_event) + + # Check if this was a predictive state update tool (e.g., write_document_local) + # If so, emit a confirm_changes tool call for the UI modal + tool_was_predictive = False + logger.debug( + f" >>> Checking predictive state: current_tool='{self.current_tool_call_name}', " + f"predict_config={list(self.predict_state_config.keys()) if self.predict_state_config else 'None'}" + ) + for state_key, config in self.predict_state_config.items(): + # Check if this tool call matches a predictive config + # We need to match against self.current_tool_call_name + if self.current_tool_call_name and config["tool"] == self.current_tool_call_name: + logger.info( + f" >>> Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'" + ) + tool_was_predictive = True + break + + if tool_was_predictive and self.require_confirmation: + # Emit confirm_changes tool call sequence + confirm_call_id = generate_event_id() + + logger.info(" >>> Emitting confirm_changes tool call for predictive update") + + # Track confirm_changes tool call for MessagesSnapshotEvent (so it persists after RUN_FINISHED) + self.pending_tool_calls.append( + { + "id": confirm_call_id, + "type": "function", + "function": { + "name": "confirm_changes", + "arguments": "{}", + }, + } + ) + + # Start the confirm_changes tool call + confirm_start = ToolCallStartEvent( + tool_call_id=confirm_call_id, + tool_call_name="confirm_changes", + ) + events.append(confirm_start) + + # Empty args for confirm_changes + confirm_args = ToolCallArgsEvent( + tool_call_id=confirm_call_id, + delta="{}", + ) + events.append(confirm_args) + + # End the confirm_changes tool call + confirm_end = ToolCallEndEvent( + tool_call_id=confirm_call_id, + ) + events.append(confirm_end) + + # Emit MessagesSnapshotEvent so confirm_changes persists after RUN_FINISHED + # Build assistant message with pending confirm_changes tool call + assistant_message = { + "id": generate_event_id(), + "role": "assistant", + "tool_calls": self.pending_tool_calls.copy(), # Includes confirm_changes + } + + # Build complete messages array: input messages + assistant message + any tool results + all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy() + + # Emit MessagesSnapshotEvent + messages_snapshot_event = MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, messages=all_messages + ) + logger.info( + f" >>> Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages" + ) + events.append(messages_snapshot_event) + + # Set flag to stop the run after this - we're waiting for user response + self.should_stop_after_confirm = True + logger.info(" >>> Set flag to stop run after confirm_changes") + elif tool_was_predictive: + logger.info(" >>> Skipping confirm_changes - require_confirmation is False") + + # Clear pending updates and reset tool name tracker + self.pending_state_updates.clear() + self.last_emitted_state.clear() + self.current_tool_call_name = None # Reset for next tool call + + elif isinstance(content, FunctionApprovalRequestContent): + # Human in the loop - function approval request + logger.info("=== FUNCTION APPROVAL REQUEST ===") + logger.info(f" Function: {content.function_call.name}") + logger.info(f" Call ID: {content.function_call.call_id}") + + # Parse the arguments to extract state for predictive UI updates + parsed_args = content.function_call.parse_arguments() + logger.info(f" Parsed args keys: {list(parsed_args.keys()) if parsed_args else 'None'}") + + # Check if this matches our predict_state_config and emit state + if parsed_args and self.predict_state_config: + logger.info(f" Checking predict_state_config: {self.predict_state_config}") + for state_key, config in self.predict_state_config.items(): + if config["tool"] == content.function_call.name: + tool_arg_name = config["tool_argument"] + logger.info( + f" MATCHED tool '{content.function_call.name}' for state key '{state_key}', arg='{tool_arg_name}'" + ) + + # Extract the state value + if tool_arg_name == "*": + state_value = parsed_args + elif tool_arg_name in parsed_args: + state_value = parsed_args[tool_arg_name] + else: + logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args") + continue + + # Update current state + self.current_state[state_key] = state_value + logger.info( + f" >>> Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}" + ) + + # Emit state snapshot + state_snapshot = StateSnapshotEvent( + snapshot=self.current_state, + ) + events.append(state_snapshot) + + # The tool call has been streamed already (Start/Args events) + # Now we need to close it with an End event before the agent waits for approval + if content.function_call.call_id: + end_event = ToolCallEndEvent( + tool_call_id=content.function_call.call_id, + ) + logger.info( + f" >>> Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'" + ) + events.append(end_event) + + # Emit custom event for approval request + # Note: In AG-UI protocol, the frontend handles interrupts automatically + # when it sees a tool call with the configured name (via predict_state_config) + # This custom event is for additional metadata if needed + approval_event = CustomEvent( + name="function_approval_request", + value={ + "id": content.id, + "function_call": { + "call_id": content.function_call.call_id, + "name": content.function_call.name, + "arguments": content.function_call.parse_arguments(), + }, + }, + ) + logger.info(f" >>> Emitting function_approval_request custom event for '{content.function_call.name}'") + events.append(approval_event) + + return events + + def create_run_started_event(self) -> RunStartedEvent: + """Create a run started event.""" + return RunStartedEvent( + run_id=self.run_id, + thread_id=self.thread_id, + ) + + def create_run_finished_event(self, result: Any = None) -> RunFinishedEvent: + """Create a run finished event.""" + return RunFinishedEvent( + run_id=self.run_id, + thread_id=self.thread_id, + result=result, + ) + + def create_message_start_event(self, message_id: str, role: str = "assistant") -> TextMessageStartEvent: + """Create a message start event.""" + return TextMessageStartEvent( + message_id=message_id, + role=role, # type: ignore + ) + + def create_message_end_event(self, message_id: str) -> TextMessageEndEvent: + """Create a message end event.""" + return TextMessageEndEvent( + message_id=message_id, + ) + + def create_state_snapshot_event(self, state: dict[str, Any]) -> StateSnapshotEvent: + """Create a state snapshot event. + + Args: + state: The complete state snapshot. + + Returns: + StateSnapshotEvent. + """ + return StateSnapshotEvent( + snapshot=state, + ) + + def create_state_delta_event(self, delta: list[dict[str, Any]]) -> StateDeltaEvent: + """Create a state delta event using JSON Patch format (RFC 6902). + + Args: + delta: List of JSON Patch operations. + + Returns: + StateDeltaEvent. + """ + return StateDeltaEvent( + delta=delta, + ) 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 new file mode 100644 index 0000000000..ebeb2dcacf --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -0,0 +1,218 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Message format conversion between AG-UI and Agent Framework.""" + +from typing import Any + +from agent_framework import ( + ChatMessage, + FunctionApprovalResponseContent, + FunctionCallContent, + Role, + TextContent, +) + +# Role mapping constants +_AGUI_TO_FRAMEWORK_ROLE = { + "user": Role.USER, + "assistant": Role.ASSISTANT, + "system": Role.SYSTEM, +} + +_FRAMEWORK_TO_AGUI_ROLE = { + Role.USER: "user", + Role.ASSISTANT: "assistant", + Role.SYSTEM: "system", +} + + +def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[ChatMessage]: + """Convert AG-UI messages to Agent Framework format. + + Args: + messages: List of AG-UI messages + + Returns: + List of Agent Framework ChatMessage objects + """ + result: list[ChatMessage] = [] + for msg in messages: + # Check for backend tool rendering results FIRST (may not have role field) + if "actionExecutionId" in msg or "actionName" in msg: + # Backend tool rendering - convert to FunctionResultContent + from agent_framework import FunctionResultContent + + tool_call_id = msg.get("actionExecutionId", "") + result_content = msg.get("result", msg.get("content", "")) + + chat_msg = ChatMessage( + role=Role.ASSISTANT, # Tool results are assistant messages + contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)], + ) + + if "id" in msg: + chat_msg.message_id = msg["id"] + + result.append(chat_msg) + continue + + role_str = msg.get("role", "user") + + # Handle tool result messages (with role="tool") + if role_str == "tool": + # Check if this is a standard tool result (has tool_call_id or toolCallId) + tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId") + result_content = msg.get("content", "") + + # Distinguish between backend tool results and approval responses + # Approval responses have {"accepted": ...} structure + is_approval = False + if result_content: + import json + + try: + parsed_content = json.loads(result_content) + is_approval = "accepted" in parsed_content + except (json.JSONDecodeError, TypeError): + is_approval = False + + # Backend tool results have non-empty content WITHOUT "accepted" field + if tool_call_id and result_content and not is_approval: + # Backend tool execution - convert to FunctionResultContent + from agent_framework import FunctionResultContent + + chat_msg = ChatMessage( + role=Role.ASSISTANT, # Tool results are assistant messages + contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)], + ) + + if "id" in msg: + chat_msg.message_id = msg["id"] + + result.append(chat_msg) + continue + else: + # Human-in-the-loop approval response - mark for special handling + content = msg.get("content", "") + chat_msg = ChatMessage( + role=Role.USER, # Approval responses are user messages + contents=[TextContent(text=content)], + ) + # Mark this as a tool result so we can detect it later + chat_msg.metadata = {"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")} # type: ignore[attr-defined] + + if "id" in msg: + chat_msg.message_id = msg["id"] + + result.append(chat_msg) + continue + + role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER) + + # Check if this message contains function approvals + if "function_approvals" in msg and msg["function_approvals"]: + # Convert function approvals to FunctionApprovalResponseContent + contents: list[Any] = [] + for approval in msg["function_approvals"]: + # Create FunctionCallContent with the modified arguments + func_call = FunctionCallContent( + call_id=approval.get("call_id", ""), + name=approval.get("name", ""), + arguments=approval.get("arguments", {}), + ) + + # Create the approval response + approval_response = FunctionApprovalResponseContent( + approved=approval.get("approved", True), + id=approval.get("id", ""), + function_call=func_call, + ) + contents.append(approval_response) + + chat_msg = ChatMessage(role=role, contents=contents) # type: ignore[arg-type] + else: + # Regular text message + content = msg.get("content", "") + if isinstance(content, str): + chat_msg = ChatMessage(role=role, contents=[TextContent(text=content)]) + else: + chat_msg = ChatMessage(role=role, contents=[TextContent(text=str(content))]) + + if "id" in msg: + chat_msg.message_id = msg["id"] + + result.append(chat_msg) + + return result + + +def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[str, Any]]: + """Convert Agent Framework messages to AG-UI format. + + Args: + messages: List of Agent Framework ChatMessage objects + + Returns: + List of AG-UI message dictionaries + """ + result: list[dict[str, Any]] = [] + for msg in messages: + role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user") + + content_text = "" + tool_calls: list[dict[str, Any]] = [] + + for content in msg.contents: + if isinstance(content, TextContent): + content_text += content.text + elif isinstance(content, FunctionCallContent): + tool_calls.append( + { + "id": content.call_id, + "type": "function", + "function": { + "name": content.name, + "arguments": content.arguments, + }, + } + ) + + agui_msg: dict[str, Any] = { + "role": role, + "content": content_text, + } + + if msg.message_id: + agui_msg["id"] = msg.message_id + + if tool_calls: + agui_msg["tool_calls"] = tool_calls + + result.append(agui_msg) + + return result + + +def extract_text_from_contents(contents: list[Any]) -> str: + """Extract text from Agent Framework contents. + + Args: + contents: List of content objects + + Returns: + Concatenated text + """ + text_parts: list[str] = [] + for content in contents: + if isinstance(content, TextContent): + text_parts.append(content.text) + elif hasattr(content, "text"): + text_parts.append(content.text) + return "".join(text_parts) + + +__all__ = [ + "agui_messages_to_agent_framework", + "agent_framework_messages_to_agui", + "extract_text_from_contents", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py new file mode 100644 index 0000000000..1440dddf36 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py @@ -0,0 +1,439 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Orchestrators for multi-turn agent flows.""" + +import json +import logging +import uuid +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Any + +from ag_ui.core import ( + BaseEvent, + RunErrorEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, +) +from agent_framework import AgentProtocol, AgentThread, TextContent + +from ._utils import generate_event_id + +if TYPE_CHECKING: + from ._agent import AgentConfig + from ._confirmation_strategies import ConfirmationStrategy + + +logger = logging.getLogger(__name__) + + +class ExecutionContext: + """Shared context for orchestrators.""" + + def __init__( + self, + input_data: dict[str, Any], + agent: AgentProtocol, + config: "AgentConfig", # noqa: F821 + confirmation_strategy: "ConfirmationStrategy | None" = None, # noqa: F821 + ): + """Initialize execution context. + + Args: + input_data: AG-UI run input containing messages, state, etc. + agent: The Agent Framework agent to execute + config: Agent configuration + confirmation_strategy: Strategy for generating confirmation messages + """ + self.input_data = input_data + self.agent = agent + self.config = config + self.confirmation_strategy = confirmation_strategy + + # Lazy-loaded properties + self._messages = None + self._last_message = None + self._run_id: str | None = None + self._thread_id: str | None = None + + @property + def messages(self): + """Get converted Agent Framework messages (lazy loaded).""" + if self._messages is None: + from ._message_adapters import agui_messages_to_agent_framework + + raw = self.input_data.get("messages", []) + self._messages = agui_messages_to_agent_framework(raw) + return self._messages + + @property + def last_message(self): + """Get the last message in the conversation (lazy loaded).""" + if self._last_message is None and self.messages: + self._last_message = self.messages[-1] + return self._last_message + + @property + def run_id(self) -> str: + """Get or generate run ID.""" + if self._run_id is None: + self._run_id = self.input_data.get("run_id") or str(uuid.uuid4()) + # This should never be None after the if block above, but satisfy type checkers + if self._run_id is None: # pragma: no cover + raise RuntimeError("Failed to initialize run_id") + return self._run_id + + @property + def thread_id(self) -> str: + """Get or generate thread ID.""" + if self._thread_id is None: + self._thread_id = self.input_data.get("thread_id") or str(uuid.uuid4()) + # This should never be None after the if block above, but satisfy type checkers + if self._thread_id is None: # pragma: no cover + raise RuntimeError("Failed to initialize thread_id") + return self._thread_id + + +class Orchestrator(ABC): + """Base orchestrator for agent execution flows.""" + + @abstractmethod + def can_handle(self, context: ExecutionContext) -> bool: + """Determine if this orchestrator handles the current request. + + Args: + context: Execution context with input data and agent + + Returns: + True if this orchestrator should handle the request + """ + ... + + @abstractmethod + async def run( + self, + context: ExecutionContext, + ) -> AsyncGenerator[BaseEvent, None]: + """Execute the orchestration and yield events. + + Args: + context: Execution context + + Yields: + AG-UI events + """ + # This is never executed - just satisfies mypy's requirement for async generators + if False: # pragma: no cover + yield + raise NotImplementedError + + +class HumanInTheLoopOrchestrator(Orchestrator): + """Handles tool approval responses from user.""" + + def can_handle(self, context: ExecutionContext) -> bool: + """Check if last message is a tool approval response. + + Args: + context: Execution context + + Returns: + True if last message is a tool result + """ + msg = context.last_message + if not msg or not hasattr(msg, "metadata"): + return False + + metadata = getattr(msg, "metadata", None) + if not metadata: + return False + + return bool(metadata.get("is_tool_result", False)) + + async def run( + self, + context: ExecutionContext, + ) -> AsyncGenerator[BaseEvent, None]: + """Process approval response and generate confirmation events. + + This implementation is extracted from the legacy _agent.py lines 144-244. + + Args: + context: Execution context + + Yields: + AG-UI events (TextMessage, RunFinished) + """ + from ._confirmation_strategies import DefaultConfirmationStrategy + from ._events import AgentFrameworkEventBridge + + logger.info("=== TOOL RESULT DETECTED (HumanInTheLoopOrchestrator) ===") + + # Create event bridge for run events + event_bridge = AgentFrameworkEventBridge( + run_id=context.run_id, + thread_id=context.thread_id, + ) + + # CRITICAL: Every AG-UI run must start with RunStartedEvent + yield event_bridge.create_run_started_event() + + # Get confirmation strategy (use default if none provided) + strategy = context.confirmation_strategy + if strategy is None: + strategy = DefaultConfirmationStrategy() + + # Parse the tool result content + tool_content_text = "" + last_message = context.last_message + if last_message: + for content in last_message.contents: + if isinstance(content, TextContent): + tool_content_text = content.text + break + + try: + tool_result = json.loads(tool_content_text) + accepted = tool_result.get("accepted", False) + steps = tool_result.get("steps", []) + + logger.info(f" Accepted: {accepted}") + logger.info(f" Steps count: {len(steps)}") + + # Emit a text message confirming execution + message_id = generate_event_id() + + yield TextMessageStartEvent(message_id=message_id, role="assistant") + + # Check if this is confirm_changes (no steps) or function approval (has steps) + if not steps: + # This is confirm_changes for predictive state updates + if accepted: + confirmation_message = strategy.on_state_confirmed() + else: + confirmation_message = strategy.on_state_rejected() + elif accepted: + # User approved - execute the enabled steps (function approval flow) + confirmation_message = strategy.on_approval_accepted(steps) + else: + # User rejected + confirmation_message = strategy.on_approval_rejected(steps) + + yield TextMessageContentEvent( + message_id=message_id, + delta=confirmation_message, + ) + + yield TextMessageEndEvent(message_id=message_id) + + # Emit run finished + yield event_bridge.create_run_finished_event() + + except json.JSONDecodeError: + logger.error(f"Failed to parse tool result: {tool_content_text}") + yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}") + yield event_bridge.create_run_finished_event() + + +class DefaultOrchestrator(Orchestrator): + """Standard agent execution (no special handling).""" + + def can_handle(self, context: ExecutionContext) -> bool: + """Always returns True as this is the fallback orchestrator. + + Args: + context: Execution context + + Returns: + Always True + """ + return True + + async def run( + self, + context: ExecutionContext, + ) -> AsyncGenerator[BaseEvent, None]: + """Standard agent run with event translation. + + This implements the default agent execution flow using the event bridge + to translate Agent Framework events to AG-UI events. + + Args: + context: Execution context + + Yields: + AG-UI events + """ + from ._events import AgentFrameworkEventBridge + + logger.info(f"Starting default agent run for thread_id={context.thread_id}, run_id={context.run_id}") + + # Initialize state tracking + initial_state = context.input_data.get("state", {}) + current_state: dict[str, Any] = initial_state.copy() if initial_state else {} + + # Check if agent uses structured outputs (response_format) + chat_options = getattr(context.agent, "chat_options", None) + response_format = getattr(chat_options, "response_format", None) if chat_options else None + skip_text_content = response_format is not None + + # Create event bridge + event_bridge = AgentFrameworkEventBridge( + run_id=context.run_id, + thread_id=context.thread_id, + predict_state_config=context.config.predict_state_config, + current_state=current_state, + skip_text_content=skip_text_content, + input_messages=context.input_data.get("messages", []), + require_confirmation=context.config.require_confirmation, + ) + + yield event_bridge.create_run_started_event() + + # Emit PredictState custom event if we have predictive state config + if context.config.predict_state_config: + from ag_ui.core import CustomEvent, EventType + + predict_state_value = [ + { + "state_key": state_key, + "tool": config["tool"], + "tool_argument": config["tool_argument"], + } + for state_key, config in context.config.predict_state_config.items() + ] + + yield CustomEvent( + type=EventType.CUSTOM, + name="PredictState", + value=predict_state_value, + ) + + # If we have a state schema, ensure we emit initial state snapshot + if context.config.state_schema: + # Initialize missing state fields with appropriate empty values based on schema type + for key, schema in context.config.state_schema.items(): + if key not in current_state: + # Default to empty object; use empty array if schema specifies "array" type + current_state[key] = [] if isinstance(schema, dict) and schema.get("type") == "array" else {} # type: ignore + yield event_bridge.create_state_snapshot_event(current_state) + + # Create thread for context tracking + thread = AgentThread() + thread.metadata = { # type: ignore[attr-defined] + "ag_ui_thread_id": context.thread_id, + "ag_ui_run_id": context.run_id, + } + + # Inject current state into thread metadata so agent can access it + if current_state: + thread.metadata["current_state"] = current_state # type: ignore[attr-defined] + + # Add incoming AG-UI messages to the thread history + if context.messages: + await thread.on_new_messages(context.messages) + + # Get the last message as the new input + new_message = context.last_message + if not new_message: + logger.warning("No messages provided in AG-UI input") + yield event_bridge.create_run_finished_event() + return + + # Inject current state as system message context if we have state + messages_to_run: list[Any] = [] + if current_state and context.config.state_schema: + state_json = json.dumps(current_state, indent=2) + from agent_framework import ChatMessage + + state_context_msg = ChatMessage( + role="system", + contents=[ + TextContent( + text=f"""Current state of the application: +{state_json} + +When modifying state, you MUST include ALL existing data plus your changes. +For example, if adding a new ingredient, include all existing ingredients PLUS the new one. +Never replace existing data - always append or merge.""" + ) + ], + ) + messages_to_run.append(state_context_msg) + + messages_to_run.append(new_message) + + # Collect all updates to get the final structured output + all_updates: list[Any] = [] + async for update in context.agent.run_stream(messages_to_run, thread=thread): + all_updates.append(update) + events = await event_bridge.from_agent_run_update(update) + for event in events: + yield event + + # After agent completes, check if we should stop (waiting for user to confirm changes) + if event_bridge.should_stop_after_confirm: + logger.info(" >>> Stopping run after confirm_changes - waiting for user response") + yield event_bridge.create_run_finished_event() + return + + # After streaming completes, check if agent has response_format and extract structured output + if all_updates and response_format: + from agent_framework import AgentRunResponse + from pydantic import BaseModel + + logger.info(f"Processing structured output, update count: {len(all_updates)}") + + # Convert streaming updates to final response to get the structured output + final_response = AgentRunResponse.from_agent_run_response_updates( + all_updates, output_format_type=response_format + ) + + if final_response.value and isinstance(final_response.value, BaseModel): + # Convert Pydantic model to dict + response_dict = final_response.value.model_dump(mode="json", exclude_none=True) + logger.info(f"Received structured output: {list(response_dict.keys())}") + + # Extract state fields based on state_schema + state_updates: dict[str, Any] = {} + + if context.config.state_schema: + # Use state_schema to determine which fields are state + for state_key in context.config.state_schema.keys(): + if state_key in response_dict: + state_updates[state_key] = response_dict[state_key] + else: + # No schema: treat all non-message fields as state + state_updates = {k: v for k, v in response_dict.items() if k != "message"} + + # Apply state updates if any found + if state_updates: + current_state.update(state_updates) + + # Emit StateSnapshotEvent with the updated state + state_snapshot = event_bridge.create_state_snapshot_event(current_state) + yield state_snapshot + logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}") + + # If there's a message field, emit it as chat text + if "message" in response_dict and response_dict["message"]: + message_id = generate_event_id() + yield TextMessageStartEvent(message_id=message_id, role="assistant") + yield TextMessageContentEvent(message_id=message_id, delta=response_dict["message"]) + yield TextMessageEndEvent(message_id=message_id) + logger.info(f"Emitted conversational message: {response_dict['message'][:100]}...") + + if event_bridge.current_message_id: + yield event_bridge.create_message_end_event(event_bridge.current_message_id) + + yield event_bridge.create_run_finished_event() + logger.info(f"Completed agent run for thread_id={context.thread_id}, run_id={context.run_id}") + + +__all__ = [ + "Orchestrator", + "ExecutionContext", + "HumanInTheLoopOrchestrator", + "DefaultOrchestrator", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_types.py b/python/packages/ag-ui/agent_framework_ag_ui/_types.py new file mode 100644 index 0000000000..da7d80ea66 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_types.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Type definitions for AG-UI integration.""" + +from typing import Any, TypedDict + + +class PredictStateConfig(TypedDict): + """Configuration for predictive state updates.""" + + state_key: str + tool: str + tool_argument: str | None + + +class RunMetadata(TypedDict): + """Metadata for agent run.""" + + run_id: str + thread_id: str + predict_state: list[PredictStateConfig] | None + + +class AgentState(TypedDict): + """Base state for AG-UI agents.""" + + messages: list[Any] | None diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py new file mode 100644 index 0000000000..e30d682fcb --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Utility functions for AG-UI integration.""" + +import copy +import uuid +from dataclasses import asdict, is_dataclass +from datetime import date, datetime +from typing import Any + + +def generate_event_id() -> str: + """Generate a unique event ID.""" + return str(uuid.uuid4()) + + +def merge_state(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: + """Merge state updates. + + Args: + current: Current state dictionary + update: Update to apply + + Returns: + Merged state + """ + result = copy.deepcopy(current) + result.update(update) + return result + + +def make_json_safe(obj: Any) -> Any: # noqa: ANN401 + """Make an object JSON serializable. + + Args: + obj: Object to make JSON safe + + Returns: + JSON-serializable version of the object + """ + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if is_dataclass(obj): + return asdict(obj) # type: ignore[arg-type] + if hasattr(obj, "model_dump"): + return obj.model_dump() # type: ignore[no-any-return] + if hasattr(obj, "dict"): + return obj.dict() # type: ignore[no-any-return] + if hasattr(obj, "__dict__"): + return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc] + if isinstance(obj, (list, tuple)): + return [make_json_safe(item) for item in obj] # type: ignore[misc] + if isinstance(obj, dict): + return {key: make_json_safe(value) for key, value in obj.items()} # type: ignore[misc] + return str(obj) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/py.typed b/python/packages/ag-ui/agent_framework_ag_ui/py.typed new file mode 100644 index 0000000000..7632ecf775 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/python/packages/ag-ui/examples/.env.example b/python/packages/ag-ui/examples/.env.example new file mode 100644 index 0000000000..ada219d9d9 --- /dev/null +++ b/python/packages/ag-ui/examples/.env.example @@ -0,0 +1,3 @@ +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_API_KEY=your-api-key-here +PORT=8000 diff --git a/python/packages/ag-ui/examples/.vscode/settings.json b/python/packages/ag-ui/examples/.vscode/settings.json new file mode 100644 index 0000000000..0728fcf794 --- /dev/null +++ b/python/packages/ag-ui/examples/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.analysis.extraPaths": [ + "${workspaceFolder}/packages/ag-ui/examples" + ] +} diff --git a/python/packages/ag-ui/examples/README.md b/python/packages/ag-ui/examples/README.md new file mode 100644 index 0000000000..88887f6070 --- /dev/null +++ b/python/packages/ag-ui/examples/README.md @@ -0,0 +1,243 @@ +# Agent Framework AG-UI Integration + +AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol. + +## Installation + +```bash +pip install agent-framework-ag-ui +``` + +## Quick Start + +```python +from fastapi import FastAPI +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +# Create your agent +agent = ChatAgent( + name="my_agent", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +# Create FastAPI app and add AG-UI endpoint +app = FastAPI() +add_agent_framework_fastapi_endpoint(app, agent, "/agent") + +# Run with: uvicorn main:app --reload +``` + +## Features + +This integration supports all 7 AG-UI features: + +1. **Agentic Chat**: Basic streaming chat with tool calling support +2. **Backend Tool Rendering**: Tools executed on backend with results streamed via ToolCallResultEvent +3. **Human in the Loop**: Function approval requests for user confirmation before tool execution +4. **Agentic Generative UI**: Async tools for long-running operations with progress updates +5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls +6. **Shared State**: Bidirectional state sync using StateSnapshotEvent and StateDeltaEvent +7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution + +## Examples + +Complete examples for all features are in the `examples/` directory: + +- `examples/agents/simple_agent.py` - Basic agentic chat +- `examples/agents/weather_agent.py` - Backend tool rendering +- `examples/agents/task_planner_agent.py` - Human in the loop with approvals +- `examples/agents/research_assistant_agent.py` - Agentic generative UI +- `examples/agents/ui_generator_agent.py` - Tool-based generative UI +- `examples/agents/recipe_agent.py` - Shared state management +- `examples/agents/document_writer_agent.py` - Predictive state updates +- `examples/server/main.py` - FastAPI server with all endpoints + +Run the example server: + +```bash +cd examples/server +uvicorn main:app --reload +``` + +To enable debug logging: + +```bash +ENABLE_DEBUG_LOGGING=1 uvicorn main:app --reload +``` + +The server exposes endpoints at: +- `/agentic_chat` +- `/backend_tool_rendering` +- `/human_in_the_loop` +- `/agentic_generative_ui` +- `/tool_based_generative_ui` +- `/shared_state` +- `/predictive_state_updates` + +## Architecture + +The package uses a clean, orchestrator-based architecture: + +- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators +- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.) +- **Confirmation Strategies**: Domain-specific confirmation messages (extensible) +- **AgentFrameworkEventBridge**: Converts AgentRunResponseUpdate to AG-UI events +- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats +- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE) + +### Key Design Patterns + +- **Orchestrator Pattern**: Separates flow control from protocol translation +- **Strategy Pattern**: Pluggable confirmation message strategies +- **Context Object**: Lazy-loaded execution context passed to orchestrators +- **Event Bridge**: Stateless translation of Agent Framework events to AG-UI events + +## Advanced Usage + +### Shared State + +State is injected as system messages and updated via predictive state updates: + +```python +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent + +# Create your agent +agent = ChatAgent( + name="recipe_agent", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +state_schema = { + "recipe": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "ingredients": {"type": "array"} + } + } +} + +# Configure which tool updates which state fields +predict_state_config = { + "recipe": {"tool": "update_recipe", "tool_argument": "recipe_data"} +} + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + state_schema=state_schema, + predict_state_config=predict_state_config, +) +``` + +### Predictive State Updates + +Predictive state updates automatically stream tool arguments as optimistic state updates: + +```python +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent + +# Create your agent +agent = ChatAgent( + name="document_writer", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +predict_state_config = { + "current_title": {"tool": "write_document", "tool_argument": "title"}, + "current_content": {"tool": "write_document", "tool_argument": "content"}, +} + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + state_schema={"current_title": {"type": "string"}, "current_content": {"type": "string"}}, + predict_state_config=predict_state_config, + require_confirmation=True, # User can approve/reject changes +) +``` + +### Custom Confirmation Strategies + +Provide domain-specific confirmation messages: + +```python +from typing import Any +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent, ConfirmationStrategy + +class CustomConfirmationStrategy(ConfirmationStrategy): + def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: + return "Your custom approval message!" + + def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str: + return "Your custom rejection message!" + + def on_state_confirmed(self) -> str: + return "State changes confirmed!" + + def on_state_rejected(self) -> str: + return "State changes rejected!" + +agent = ChatAgent( + name="custom_agent", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + confirmation_strategy=CustomConfirmationStrategy(), +) +``` + +### Human in the Loop + +Human-in-the-loop is automatically handled when tools are marked for approval: + +```python +from agent_framework import ai_function + +@ai_function(approval_mode="always_require") +def sensitive_action(param: str) -> str: + """This action requires user approval.""" + return f"Executed with {param}" + +# The orchestrator automatically detects approval responses and handles them +``` + +### Custom Orchestrators + +Add custom execution flows by implementing the Orchestrator pattern: + +```python +from agent_framework_ag_ui._orchestrators import Orchestrator, ExecutionContext + +class MyCustomOrchestrator(Orchestrator): + def can_handle(self, context: ExecutionContext) -> bool: + # Return True if this orchestrator should handle the request + return context.input_data.get("custom_mode") == True + + async def run(self, context: ExecutionContext): + # Custom execution logic + yield RunStartedEvent(...) + # ... your custom flow + yield RunFinishedEvent(...) + +wrapped_agent = AgentFrameworkAgent( + agent=your_agent, + orchestrators=[MyCustomOrchestrator(), DefaultOrchestrator()], +) + +## Documentation + +For detailed documentation, see [DESIGN.md](DESIGN.md). + +## License + +MIT diff --git a/python/packages/ag-ui/examples/__init__.py b/python/packages/ag-ui/examples/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/ag-ui/examples/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/examples/__main__.py b/python/packages/ag-ui/examples/__main__.py new file mode 100644 index 0000000000..b52cf15cc0 --- /dev/null +++ b/python/packages/ag-ui/examples/__main__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Entry point for running the AG-UI examples server as a module.""" + +from .server.main import main + +if __name__ == "__main__": + main() diff --git a/python/packages/ag-ui/examples/agents/__init__.py b/python/packages/ag-ui/examples/agents/__init__.py new file mode 100644 index 0000000000..eea1a10956 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agents for AG-UI demonstration.""" diff --git a/python/packages/ag-ui/examples/agents/document_writer_agent.py b/python/packages/ag-ui/examples/agents/document_writer_agent.py new file mode 100644 index 0000000000..ca7233a5a3 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/document_writer_agent.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating predictive state updates with document writing.""" + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy + + +@ai_function +def write_document_local(document: str) -> str: + """Write a document. Use markdown formatting to format the document. + + It's good to format the document extensively so it's easy to read. + You can use all kinds of markdown. + However, do not use italic or strike-through formatting, it's reserved for another purpose. + You MUST write the full document, even when changing only a few words. + When making edits to the document, try to make them minimal - do not change every word. + Keep stories SHORT! + + Args: + document: The complete document content in markdown format + + Returns: + Confirmation that the document was written + """ + return "Document written." + + +agent = ChatAgent( + name="document_writer", + instructions=( + "You are a helpful assistant for writing documents. " + "To write the document, you MUST use the write_document_local tool. " + "You MUST write the full document, even when changing only a few words. " + "When you wrote the document, DO NOT repeat it as a message. " + "Just briefly summarize the changes you made. 2 sentences max. " + "\n\n" + "The current state of the document will be provided to you. " + "When editing, make minimal changes - do not change every word unless requested." + ), + chat_client=AzureOpenAIChatClient(), + tools=[write_document_local], +) + +document_writer_agent = AgentFrameworkAgent( + agent=agent, + name="DocumentWriter", + description="Writes and edits documents with predictive state updates", + state_schema={ + "document": {"type": "string", "description": "The current document content"}, + }, + predict_state_config={ + "document": {"tool": "write_document_local", "tool_argument": "document"}, + }, + confirmation_strategy=DocumentWriterConfirmationStrategy(), +) diff --git a/python/packages/ag-ui/examples/agents/human_in_the_loop_agent.py b/python/packages/ag-ui/examples/agents/human_in_the_loop_agent.py new file mode 100644 index 0000000000..dfa1b30c63 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/human_in_the_loop_agent.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Human-in-the-loop agent demonstrating step customization (Feature 5).""" + +from enum import Enum + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient +from pydantic import BaseModel, Field + + +class StepStatus(str, Enum): + """Status of a task step.""" + + ENABLED = "enabled" + DISABLED = "disabled" + + +class TaskStep(BaseModel): + """A single step in a task execution plan.""" + + description: str = Field(..., description="The text of the step in imperative form (e.g., 'Dig hole', 'Open door')") + status: StepStatus = Field(default=StepStatus.ENABLED, description="Whether the step is enabled or disabled") + + +@ai_function( + name="generate_task_steps", + description="Generate execution steps for a task", + approval_mode="always_require", +) +def generate_task_steps(steps: list[TaskStep]) -> str: + """Make up 10 steps (only a couple of words per step) that are required for a task. + + The step should be in imperative form (i.e. Dig hole, Open door, ...). + Each step will have status='enabled' by default. + + Args: + steps: An array of 10 step objects, each containing description and status + + Returns: + Confirmation message + """ + return f"Generated {len(steps)} execution steps for the task." + + +# Create the human-in-the-loop agent using tool-based approach for predictive state +human_in_the_loop_agent = ChatAgent( + name="human_in_the_loop_agent", + instructions="""You are a helpful assistant that can perform any task by breaking it down into steps. + + When asked to perform a task, you MUST call the `generate_task_steps` function with the proper + number of steps per the request. + + Rules for steps: + - Each step description should be in imperative form (e.g., "Dig hole", "Open door", "Prepare ingredients") + - Each step should be brief (only a couple of words) + - All steps must have status='enabled' initially + + Example steps for "Build a robot": + 1. "Design blueprint" + 2. "Gather components" + 3. "Assemble frame" + 4. "Install motors" + 5. "Wire electronics" + 6. "Program controller" + 7. "Test movements" + 8. "Add sensors" + 9. "Calibrate systems" + 10. "Final testing" + + After calling the function, provide a brief acknowledgment like: + "I've created a plan with 10 steps. You can customize which steps to enable before I proceed." + """, + chat_client=AzureOpenAIChatClient(), + tools=[generate_task_steps], +) diff --git a/python/packages/ag-ui/examples/agents/recipe_agent.py b/python/packages/ag-ui/examples/agents/recipe_agent.py new file mode 100644 index 0000000000..2a5b94e1cc --- /dev/null +++ b/python/packages/ag-ui/examples/agents/recipe_agent.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Recipe agent example demonstrating shared state management (Feature 3).""" + +from enum import Enum + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient +from pydantic import BaseModel, Field + +from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy + + +class SkillLevel(str, Enum): + """The skill level required for the recipe.""" + + BEGINNER = "Beginner" + INTERMEDIATE = "Intermediate" + ADVANCED = "Advanced" + + +class CookingTime(str, Enum): + """The cooking time of the recipe.""" + + FIVE_MIN = "5 min" + FIFTEEN_MIN = "15 min" + THIRTY_MIN = "30 min" + FORTY_FIVE_MIN = "45 min" + SIXTY_PLUS_MIN = "60+ min" + + +class Ingredient(BaseModel): + """An ingredient with its details.""" + + icon: str = Field(..., description="Emoji icon representing the ingredient (e.g., 🥕)") + name: str = Field(..., description="Name of the ingredient") + amount: str = Field(..., description="Amount or quantity of the ingredient") + + +class Recipe(BaseModel): + """A complete recipe.""" + + title: str = Field(..., description="The title of the recipe") + skill_level: SkillLevel = Field(..., description="The skill level required") + special_preferences: list[str] = Field( + default_factory=list, description="Dietary preferences (e.g., Vegetarian, Gluten-free)" + ) + cooking_time: CookingTime = Field(..., description="The estimated cooking time") + ingredients: list[Ingredient] = Field(..., description="Complete list of ingredients") + instructions: list[str] = Field(..., description="Step-by-step cooking instructions") + + +@ai_function +def update_recipe(recipe: Recipe) -> str: + """Update the recipe with new or modified content. + + You MUST write the complete recipe with ALL fields, even when changing only a few items. + When modifying an existing recipe, include ALL existing ingredients and instructions plus your changes. + NEVER delete existing data - only add or modify. + + Args: + recipe: The complete recipe object with all details + + Returns: + Confirmation that the recipe was updated + """ + return "Recipe updated." + + +# Create the recipe agent using tool-based approach for streaming +agent = ChatAgent( + name="recipe_agent", + instructions="""You are a helpful recipe assistant that creates and modifies recipes. + + CRITICAL RULES: + 1. You will receive the current recipe state in the system context + 2. To update the recipe, you MUST use the update_recipe tool + 3. When modifying a recipe, ALWAYS include ALL existing data plus your changes in the tool call + 4. NEVER delete existing ingredients or instructions - only add or modify + 5. After calling the tool, provide a brief conversational message (1-2 sentences) + + When creating a NEW recipe: + - Provide all required fields: title, skill_level, cooking_time, ingredients, instructions + - Use actual emojis for ingredient icons (🥕 🧄 🧅 🍅 🌿 🍗 🥩 🧀) + - Leave special_preferences empty unless specified + - Message: "Here's your recipe!" or similar + + When MODIFYING or IMPROVING an existing recipe: + - Include ALL existing ingredients + any new ones + - Include ALL existing instructions + any new/modified ones + - Update other fields as needed + - Message: Explain what you improved (e.g., "I upgraded the ingredients to premium quality") + - When asked to "improve", enhance with: + * Better ingredients (upgrade quality, add complementary flavors) + * More detailed instructions + * Professional techniques + * Adjust skill_level if complexity changes + * Add relevant special_preferences + + Example improvements: + - Upgrade "chicken" → "organic free-range chicken breast" + - Add herbs: basil, oregano, thyme + - Add aromatics: garlic, shallots + - Add finishing touches: lemon zest, fresh parsley + - Make instructions more detailed and professional + """, + chat_client=AzureOpenAIChatClient(), + tools=[update_recipe], +) + +recipe_agent = AgentFrameworkAgent( + agent=agent, + name="RecipeAgent", + description="Creates and modifies recipes with streaming state updates", + state_schema={ + "recipe": {"type": "object", "description": "The current recipe"}, + }, + predict_state_config={ + "recipe": {"tool": "update_recipe", "tool_argument": "recipe"}, + }, + confirmation_strategy=RecipeConfirmationStrategy(), +) diff --git a/python/packages/ag-ui/examples/agents/research_assistant_agent.py b/python/packages/ag-ui/examples/agents/research_assistant_agent.py new file mode 100644 index 0000000000..60d142e2c2 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/research_assistant_agent.py @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating agentic generative UI with custom events during execution.""" + +import asyncio + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent + + +@ai_function +async def research_topic(topic: str) -> str: + """Research a topic and generate a comprehensive report. + + Args: + topic: The topic to research + + Returns: + Research report + """ + # Simulate multi-step research process + steps = [ + ("Searching databases", 1.0), + ("Analyzing sources", 1.5), + ("Synthesizing information", 1.0), + ("Generating report", 0.5), + ] + + results: list[str] = [] + for step_name, duration in steps: + await asyncio.sleep(duration) + results.append(f"- {step_name}: completed") + + return f"Research report on '{topic}':\n" + "\n".join(results) + + +@ai_function +async def create_presentation(title: str, num_slides: int) -> str: + """Create a presentation with multiple slides. + + Args: + title: Presentation title + num_slides: Number of slides to create + + Returns: + Presentation summary + """ + # Simulate slide generation + slides: list[str] = [] + for i in range(num_slides): + await asyncio.sleep(0.5) + slides.append(f"Slide {i + 1}: Content for {title}") + + return f"Created presentation '{title}' with {num_slides} slides:\n" + "\n".join(slides) + + +@ai_function +async def analyze_data(dataset: str) -> str: + """Analyze a dataset and produce insights. + + Args: + dataset: The dataset name to analyze + + Returns: + Analysis results + """ + # Simulate data analysis phases + phases = [ + ("Loading data", 0.8), + ("Cleaning data", 1.0), + ("Running statistical analysis", 1.2), + ("Generating visualizations", 0.7), + ] + + insights: list[str] = [] + for phase_name, duration in phases: + await asyncio.sleep(duration) + insights.append(f"- {phase_name}: done") + + return f"Analysis of '{dataset}':\n" + "\n".join(insights) + + +agent = ChatAgent( + name="research_assistant", + instructions=( + "You are a research and analysis assistant. " + "You can research topics, create presentations, and analyze data. " + "Use the available tools to help users with their research needs." + ), + chat_client=AzureOpenAIChatClient(), + tools=[research_topic, create_presentation, analyze_data], +) + +research_assistant_agent = AgentFrameworkAgent( + agent=agent, + name="ResearchAssistant", + description="Research assistant that emits progress events during task execution", +) diff --git a/python/packages/ag-ui/examples/agents/simple_agent.py b/python/packages/ag-ui/examples/agents/simple_agent.py new file mode 100644 index 0000000000..4831f1442c --- /dev/null +++ b/python/packages/ag-ui/examples/agents/simple_agent.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Simple agentic chat example (Feature 1: Agentic Chat).""" + +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient + +# Create a simple chat agent +agent = ChatAgent( + name="simple_chat_agent", + instructions="You are a helpful assistant. Be concise and friendly.", + chat_client=AzureOpenAIChatClient(), +) diff --git a/python/packages/ag-ui/examples/agents/task_planner_agent.py b/python/packages/ag-ui/examples/agents/task_planner_agent.py new file mode 100644 index 0000000000..58d8b8c556 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/task_planner_agent.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating human-in-the-loop with function approvals.""" + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy + + +@ai_function(approval_mode="always_require") +def create_calendar_event(title: str, date: str, time: str) -> str: + """Create a calendar event. + + Args: + title: The event title + date: The event date (YYYY-MM-DD) + time: The event time (HH:MM) + + Returns: + Confirmation message + """ + return f"Calendar event '{title}' created for {date} at {time}" + + +@ai_function(approval_mode="always_require") +def send_email(to: str, subject: str, body: str) -> str: + """Send an email. + + Args: + to: Recipient email address + subject: Email subject + body: Email body text + + Returns: + Confirmation message + """ + return f"Email sent to {to} with subject '{subject}'" + + +@ai_function(approval_mode="always_require") +def book_meeting_room(room_name: str, date: str, start_time: str, end_time: str) -> str: + """Book a meeting room. + + Args: + room_name: The meeting room name + date: The booking date (YYYY-MM-DD) + start_time: Start time (HH:MM) + end_time: End time (HH:MM) + + Returns: + Confirmation message + """ + return f"Meeting room '{room_name}' booked for {date} from {start_time} to {end_time}" + + +agent = ChatAgent( + name="task_planner", + instructions=( + "You are a helpful assistant that plans and executes tasks. " + "You have access to calendar, email, and meeting room booking functions. " + "All of these actions require user approval before execution." + ), + chat_client=AzureOpenAIChatClient(), + tools=[create_calendar_event, send_email, book_meeting_room], +) + +task_planner_agent = AgentFrameworkAgent( + agent=agent, + name="TaskPlanner", + description="Plans and executes tasks with user approval", + confirmation_strategy=TaskPlannerConfirmationStrategy(), +) diff --git a/python/packages/ag-ui/examples/agents/task_steps_agent.py b/python/packages/ag-ui/examples/agents/task_steps_agent.py new file mode 100644 index 0000000000..ef7a438d9b --- /dev/null +++ b/python/packages/ag-ui/examples/agents/task_steps_agent.py @@ -0,0 +1,318 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Task steps agent demonstrating agentic generative UI (Feature 6).""" + +import asyncio +from collections.abc import AsyncGenerator +from enum import Enum +from typing import Any + +from ag_ui.core import ( + EventType, + MessagesSnapshotEvent, + RunFinishedEvent, + StateDeltaEvent, + StateSnapshotEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallStartEvent, +) +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient +from pydantic import BaseModel, Field + +from agent_framework_ag_ui import AgentFrameworkAgent + + +class StepStatus(str, Enum): + """Status of a task step.""" + + PENDING = "pending" + COMPLETED = "completed" + + +class TaskStep(BaseModel): + """A single step in a task.""" + + description: str = Field( + ..., description="The text of the step in gerund form (e.g., 'Digging hole', 'Opening door')" + ) + status: StepStatus = Field(default=StepStatus.PENDING, description="The status of the step") + + +@ai_function +def generate_task_steps(steps: list[TaskStep]) -> str: + """Generate a list of task steps for completing a task. + + Args: + steps: Complete list of task steps with descriptions and status + + Returns: + Confirmation that steps were generated + """ + return "Steps generated." + + +# Create the task steps agent using tool-based approach for streaming +agent = ChatAgent( + name="task_steps_agent", + instructions="""You are a helpful assistant that breaks down tasks into actionable steps. + + When asked to perform a task, you MUST: + 1. Use the generate_task_steps tool to create the steps + 2. Pay attention to how many steps the user requests (if specified) + 3. If no specific number is mentioned, use a reasonable number of steps (typically 5-10) + 4. Each step description should be in gerund form (e.g., "Designing spacecraft", "Training astronauts") + 5. Each step should be brief (only 2-4 words) + 6. All steps must have status='pending' + 7. After calling the tool, provide a brief conversational message (one sentence) saying you created the plan + + Example steps for "Build a treehouse in 5 steps": + - "Selecting location" + - "Gathering materials" + - "Assembling frame" + - "Installing platform" + - "Adding finishing touches" + """, + chat_client=AzureOpenAIChatClient(), + tools=[generate_task_steps], +) + +task_steps_agent = AgentFrameworkAgent( + agent=agent, + name="TaskStepsAgent", + description="Generates task steps with streaming state updates", + state_schema={ + "steps": {"type": "array", "description": "The list of task steps"}, + }, + predict_state_config={ + "steps": { + "tool": "generate_task_steps", + "tool_argument": "steps", + } + }, + require_confirmation=False, # Agentic generative UI updates automatically without confirmation +) + + +# Wrap the agent's run method to add step execution simulation +class TaskStepsAgentWithExecution: + """Wrapper that adds step execution simulation after plan generation. + + This wrapper delegates to AgentFrameworkAgent but is recognized as compatible + by add_agent_framework_fastapi_endpoint since it implements run_agent(). + """ + + def __init__(self, base_agent: AgentFrameworkAgent): + """Initialize wrapper with base agent.""" + self._base_agent = base_agent + + @property + def name(self) -> str: + """Delegate to base agent.""" + return self._base_agent.name + + @property + def description(self) -> str: + """Delegate to base agent.""" + return self._base_agent.description + + def __getattr__(self, name: str) -> Any: + """Delegate all other attribute access to base agent.""" + return getattr(self._base_agent, name) + + async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, None]: + """Run the agent and then simulate step execution.""" + import logging + import uuid + + logger = logging.getLogger(__name__) + logger.info(">>> TaskStepsAgentWithExecution.run_agent() called - wrapper is active") + + # First, run the base agent to generate the plan - buffer text messages + final_state: dict[str, Any] | None = None + run_finished_event: Any = None + tool_call_id: str | None = None + buffered_text_events: list[Any] = [] # Buffer text from first LLM call + + async for event in self._base_agent.run_agent(input_data): + event_type_str = str(event.type) if hasattr(event, "type") else type(event).__name__ + logger.info(f">>> Processing event: {event_type_str}") + + match event: + case StateSnapshotEvent(snapshot=snapshot): + final_state = snapshot + logger.info(f">>> Captured STATE_SNAPSHOT event with state: {final_state}") + yield event + case RunFinishedEvent(): + run_finished_event = event + logger.info(">>> Captured RUN_FINISHED event - will send after step execution and summary") + case ToolCallStartEvent(tool_call_id=call_id): + tool_call_id = call_id + logger.info(f">>> Captured tool_call_id: {tool_call_id}") + yield event + case TextMessageStartEvent() | TextMessageContentEvent() | TextMessageEndEvent(): + buffered_text_events.append(event) + logger.info(f">>> Buffered {event_type_str} from first LLM call") + case _: + logger.info(f">>> Yielding event immediately: {event_type_str}") + yield event + + logger.info(f">>> Base agent completed. Final state: {final_state}") + + # Now simulate executing the steps + if final_state and "steps" in final_state: + steps = final_state["steps"] + logger.info(f">>> Starting step execution simulation for {len(steps)} steps") + + for i in range(len(steps)): + logger.info(f">>> Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}") + await asyncio.sleep(1.0) # Simulate work + + # Update step to completed + steps[i]["status"] = "completed" + logger.info(f">>> Step {i + 1} marked as completed") + + # Send delta event with manual JSON patch format + delta_event = StateDeltaEvent( + type=EventType.STATE_DELTA, + delta=[ + { + "op": "replace", + "path": f"/steps/{i}/status", + "value": "completed", + } + ], + ) + logger.info(f">>> Yielding StateDeltaEvent for step {i + 1}") + yield delta_event + + # Send final snapshot + final_snapshot = StateSnapshotEvent( + type=EventType.STATE_SNAPSHOT, + snapshot={"steps": steps}, + ) + logger.info(">>> Yielding final StateSnapshotEvent with all steps completed") + yield final_snapshot + + # SECOND LLM call: Stream summary from chat client directly + logger.info(">>> Making SECOND LLM call to generate summary after step execution") + + # Get the underlying chat agent and client + chat_agent = self._base_agent.agent # type: ignore + chat_client = chat_agent.chat_client # type: ignore + + # Build messages for summary call + from agent_framework._types import ChatMessage, TextContent + + original_messages = input_data.get("messages", []) + + # Convert to ChatMessage objects if needed + messages: list[ChatMessage] = [] + for msg in original_messages: + if isinstance(msg, dict): + content_str = msg.get("content", "") + if isinstance(content_str, str): + messages.append( + ChatMessage( + role=msg.get("role", "user"), + contents=[TextContent(text=content_str)], + ) + ) + elif isinstance(msg, ChatMessage): + messages.append(msg) + + # Add completion message + messages.append( + ChatMessage( + role="user", + contents=[ + TextContent( + text="The steps have been successfully executed. Provide a brief one-sentence summary." + ) + ], + ) + ) + + # Stream the LLM response and manually emit text events + logger.info(">>> Calling chat client for summary") + + message_id = str(uuid.uuid4()) + + try: + # Emit TEXT_MESSAGE_START + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=message_id, + role="assistant", + ) + # Small delay to ensure START event is processed before CONTENT events + await asyncio.sleep(0.01) + + # Stream completion + accumulated_text = "" + async for chunk in chat_client.get_streaming_response(messages=messages): + # chunk is ChatResponseUpdate + if hasattr(chunk, "text") and chunk.text: + accumulated_text += chunk.text + # Emit TEXT_MESSAGE_CONTENT + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=message_id, + delta=chunk.text, + ) + + # Emit TEXT_MESSAGE_END + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + ) + logger.info(f">>> Summary complete: {accumulated_text}") + + # Build complete message for persistence + summary_message = { + "role": "assistant", + "content": accumulated_text, + "id": message_id, + } + final_messages = list(original_messages) + final_messages.append(summary_message) + + # Emit MessagesSnapshotEvent to persist in history + yield MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, + messages=final_messages, + ) + except Exception as e: + logger.error(f">>> Error generating summary: {e}") + # Generate a new message ID for the error + error_message_id = str(uuid.uuid4()) + # Yield TEXT_MESSAGE_START for error + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=error_message_id, + role="assistant", + ) + # Yield error message content + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=error_message_id, + delta=f"[Summary generation error: {e!s}]", + ) + # Yield TEXT_MESSAGE_END for error + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=error_message_id, + ) + else: + logger.warning(f">>> No steps found in final_state to execute. final_state={final_state}") + + # Finally send the original RUN_FINISHED event + if run_finished_event: + logger.info(">>> Yielding original RUN_FINISHED event") + yield run_finished_event + + +# Export the wrapped agent +task_steps_agent_wrapped = TaskStepsAgentWithExecution(task_steps_agent) diff --git a/python/packages/ag-ui/examples/agents/ui_generator_agent.py b/python/packages/ag-ui/examples/agents/ui_generator_agent.py new file mode 100644 index 0000000000..2456ccb5e1 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/ui_generator_agent.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating Tool-based Generative UI (Feature 5).""" + +from typing import Any + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent + + +@ai_function +def generate_haiku(english: list[str], japanese: list[str], image_name: str | None, gradient: str) -> str: + """Generate a haiku with image and gradient background (FRONTEND_RENDER). + + This tool generates UI for displaying a haiku with an image and gradient background. + The frontend should render this as a custom haiku component. + + Args: + english: English haiku lines (exactly 3 lines) + japanese: Japanese haiku lines (exactly 3 lines) + image_name: Image filename for visual accompaniment. Must be one of: + - "Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg" + - "Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg" + - "Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg" + - "Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg" + - "Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg" + - "Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg" + - "Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg" + - "Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg" + - "Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg" + - "Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg" + gradient: CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)") + + Returns: + Haiku metadata for frontend rendering + """ + return f"Haiku generated with image: {image_name}" + + +@ai_function +def create_chart(chart_type: str, data_points: list[dict[str, Any]], title: str) -> str: + """Create an interactive chart (FRONTEND_RENDER). + + This tool creates chart specifications for frontend rendering. + The frontend should render this as an interactive chart component. + + Args: + chart_type: Type of chart (bar, line, pie, scatter) + data_points: Data points for the chart + title: Chart title + + Returns: + Chart specification for frontend rendering + """ + return f"Chart '{title}' created with {len(data_points)} data points" + + +@ai_function +def display_timeline(events: list[dict[str, Any]], start_date: str, end_date: str) -> str: + """Display an interactive timeline (FRONTEND_RENDER). + + This tool creates timeline specifications for frontend rendering. + The frontend should render this as an interactive timeline component. + + Args: + events: Events to display on the timeline + start_date: Timeline start date + end_date: Timeline end date + + Returns: + Timeline specification for frontend rendering + """ + return f"Timeline created with {len(events)} events from {start_date} to {end_date}" + + +@ai_function +def show_comparison_table(items: list[dict[str, Any]], columns: list[str]) -> str: + """Show a comparison table (FRONTEND_RENDER). + + This tool creates table specifications for frontend rendering. + The frontend should render this as an interactive comparison table. + + Args: + items: Items to compare + columns: Column names + + Returns: + Table specification for frontend rendering + """ + return f"Comparison table created with {len(items)} items and {len(columns)} columns" + + +# Create the UI generator agent using tool-based approach with forced tool usage +agent = ChatAgent( + name="ui_generator", + instructions="""You MUST use the provided tools to generate content. Never respond with plain text descriptions. + + For haiku requests: + - Call generate_haiku tool with all 4 required parameters + - English: 3 lines + - Japanese: 3 lines + - image_name: Choose from available images + - gradient: CSS gradient string + + For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table). + """, + chat_client=AzureOpenAIChatClient(), + tools=[generate_haiku, create_chart, display_timeline, show_comparison_table], + # Force tool usage - the LLM MUST call a tool, cannot respond with plain text + chat_options={"tool_choice": "required"}, +) + +ui_generator_agent = AgentFrameworkAgent( + agent=agent, + name="UIGenerator", + description="Generates custom UI components through tool calls", +) diff --git a/python/packages/ag-ui/examples/agents/weather_agent.py b/python/packages/ag-ui/examples/agents/weather_agent.py new file mode 100644 index 0000000000..a224bb7cd0 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/weather_agent.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Weather agent example demonstrating backend tool rendering.""" + +from typing import Any + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + + +@ai_function +def get_weather(location: str) -> dict[str, Any]: + """Get the current weather for a location. + + Args: + location: The city or location to get weather for. + + Returns: + Weather information as a dictionary with temperatures in Celsius. + """ + # Simulated weather data with structured format (temperatures in Celsius for dojo UI) + weather_data = { + "seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75, "wind_speed": 12, "feels_like": 10}, + "san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85, "wind_speed": 8, "feels_like": 13}, + "new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60, "wind_speed": 10, "feels_like": 17}, + "miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90, "wind_speed": 5, "feels_like": 32}, + "chicago": {"temperature": 9, "conditions": "windy", "humidity": 65, "wind_speed": 20, "feels_like": 6}, + } + + location_lower = location.lower() + if location_lower in weather_data: + return weather_data[location_lower] + + return { + "temperature": 21, + "conditions": "partly cloudy", + "humidity": 50, + "wind_speed": 10, + "feels_like": 20, + } + + +@ai_function +def get_forecast(location: str, days: int = 3) -> str: + """Get the weather forecast for a location. + + Args: + location: The city or location to get forecast for. + days: Number of days to forecast (default: 3). + + Returns: + Forecast information string. + """ + forecast: list[str] = [] + for day in range(1, min(days, 7) + 1): + forecast.append(f"Day {day}: Partly cloudy, {60 + day * 2}°F") + + return f"{days}-day forecast for {location}:\n" + "\n".join(forecast) + + +# Create the weather agent +weather_agent = ChatAgent( + name="weather_agent", + instructions=( + "You are a helpful weather assistant. " + "Use the get_weather and get_forecast functions to help users with weather information. " + "Always provide friendly and informative responses." + ), + chat_client=AzureOpenAIChatClient(), + tools=[get_weather, get_forecast], +) diff --git a/python/packages/ag-ui/examples/server/__init__.py b/python/packages/ag-ui/examples/server/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/ag-ui/examples/server/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/examples/server/api/__init__.py b/python/packages/ag-ui/examples/server/api/__init__.py new file mode 100644 index 0000000000..e50a96d510 --- /dev/null +++ b/python/packages/ag-ui/examples/server/api/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""API endpoints for AG-UI examples.""" diff --git a/python/packages/ag-ui/examples/server/api/backend_tool_rendering.py b/python/packages/ag-ui/examples/server/api/backend_tool_rendering.py new file mode 100644 index 0000000000..fb8f88e6a4 --- /dev/null +++ b/python/packages/ag-ui/examples/server/api/backend_tool_rendering.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Backend tool rendering endpoint.""" + +from fastapi import FastAPI + +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +from ...agents.weather_agent import weather_agent + + +def register_backend_tool_rendering(app: FastAPI) -> None: + """Register the backend tool rendering endpoint. + + Args: + app: The FastAPI application. + """ + add_agent_framework_fastapi_endpoint( + app, + weather_agent, + "/backend_tool_rendering", + ) diff --git a/python/packages/ag-ui/examples/server/main.py b/python/packages/ag-ui/examples/server/main.py new file mode 100644 index 0000000000..6841f3db20 --- /dev/null +++ b/python/packages/ag-ui/examples/server/main.py @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example FastAPI server with AG-UI endpoints.""" + +import logging +import os + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +from ..agents.document_writer_agent import document_writer_agent +from ..agents.human_in_the_loop_agent import human_in_the_loop_agent +from ..agents.recipe_agent import recipe_agent +from ..agents.simple_agent import agent as simple_agent +from ..agents.task_steps_agent import task_steps_agent_wrapped as task_steps_agent # Custom wrapper +from ..agents.ui_generator_agent import ui_generator_agent +from ..agents.weather_agent import weather_agent + +# Configure logging to file and console (disabled by default - set ENABLE_DEBUG_LOGGING=1 to enable) +if os.getenv("ENABLE_DEBUG_LOGGING"): + log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "ag_ui_server.log") + + # Remove any existing handlers + root_logger = logging.getLogger() + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # Configure new handlers + file_handler = logging.FileHandler(log_file, mode="w") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) + + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) + + root_logger.addHandler(file_handler) + root_logger.addHandler(console_handler) + root_logger.setLevel(logging.INFO) + + # Explicitly set log levels for our modules + logging.getLogger("agent_framework_ag_ui").setLevel(logging.INFO) + logging.getLogger("agent_framework").setLevel(logging.INFO) + + logger = logging.getLogger(__name__) + logger.info(f"AG-UI Examples Server starting... Logs writing to: {log_file}") + +app = FastAPI(title="Agent Framework AG-UI Example Server") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Agentic Chat - basic chat agent +add_agent_framework_fastapi_endpoint( + app=app, + agent=simple_agent, + path="/agentic_chat", +) + +# Backend Tool Rendering - agent with tools +add_agent_framework_fastapi_endpoint( + app=app, + agent=weather_agent, + path="/backend_tool_rendering", +) + +# Shared State - recipe agent with structured output +add_agent_framework_fastapi_endpoint( + app=app, + agent=recipe_agent, + path="/shared_state", +) + +# Predictive State Updates - document writer with predictive state +add_agent_framework_fastapi_endpoint( + app=app, + agent=document_writer_agent, + path="/predictive_state_updates", +) + +# Human in the Loop - human-in-the-loop agent with step customization +add_agent_framework_fastapi_endpoint( + app=app, + agent=human_in_the_loop_agent, + path="/human_in_the_loop", + state_schema={"steps": {"type": "array"}}, + predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}}, +) + +# Agentic Generative UI - task steps agent with streaming state updates +add_agent_framework_fastapi_endpoint( + app=app, + agent=task_steps_agent, # type: ignore[arg-type] + path="/agentic_generative_ui", +) + +# Tool-based Generative UI - UI generator with frontend-rendered tools +add_agent_framework_fastapi_endpoint( + app=app, + agent=ui_generator_agent, + path="/tool_based_generative_ui", +) + + +def main(): + """Run the server.""" + port = int(os.getenv("PORT", "8888")) + host = os.getenv("HOST", "127.0.0.1") + + # Use log_config=None to prevent uvicorn from reconfiguring logging + # This preserves our file + console logging setup + uvicorn.run( + app, + host=host, + port=port, + log_config=None, + ) + + +if __name__ == "__main__": + main() diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md new file mode 100644 index 0000000000..2d1219bed4 --- /dev/null +++ b/python/packages/ag-ui/getting_started/README.md @@ -0,0 +1,705 @@ +# Getting Started with AG-UI (Python) + +The AG-UI (Agent UI) protocol provides a standardized way for client applications to interact with AI agents over HTTP. This tutorial demonstrates how to build both server and client applications using the AG-UI protocol with Python. + +## What is AG-UI? + +AG-UI is a protocol that enables: +- **Remote agent hosting**: Host AI agents as web services that can be accessed by multiple clients +- **Streaming responses**: Real-time streaming of agent responses using Server-Sent Events (SSE) +- **Standardized communication**: Consistent message format for agent interactions +- **Thread management**: Maintain conversation context across multiple requests +- **Advanced features**: Human-in-the-loop, state management, tool rendering + +## Prerequisites + +Before you begin, ensure you have the following: + +- Python 3.10 or later +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (for DefaultAzureCredential) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource + +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). + +**Note**: These samples use `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, or environment variables). For more information, see the [Azure Identity documentation](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential). + +> **Warning** +> The AG-UI protocol is still under development and subject to change. +> We will keep these samples updated as the protocol evolves. + +## Step 1: Creating an AG-UI Server + +The AG-UI server hosts your AI agent and exposes it via HTTP endpoints using FastAPI. + +### Install Required Packages + +```bash +pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn +``` + +Or using uv: + +```bash +uv pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn +``` + +### Server Code + +Create a file named `server.py`: + +```python +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI server example.""" + +import os + +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from fastapi import FastAPI + +# Read required configuration +endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") +deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") + +if not endpoint: + raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") +if not deployment_name: + raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required") + +# Create the AI agent +agent = ChatAgent( + name="AGUIAssistant", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint=endpoint, + deployment_name=deployment_name, + ), +) + +# Create FastAPI app +app = FastAPI(title="AG-UI Server") + +# Register the AG-UI endpoint +add_agent_framework_fastapi_endpoint(app, agent, "/") + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=5100) +``` + +### Key Concepts + +- **`add_agent_framework_fastapi_endpoint`**: Registers the AG-UI endpoint with automatic request/response handling and SSE streaming +- **`ChatAgent`**: The agent that will handle incoming requests +- **FastAPI Integration**: Uses FastAPI's native async support for streaming responses +- **Instructions**: The agent is created with default instructions, which can be overridden by client messages +- **Configuration**: `AzureOpenAIChatClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly + +**Alternative (simpler)**: Use environment variables only: + +```python +# No need to read environment variables manually +agent = ChatAgent( + name="AGUIAssistant", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient(), # Reads from environment automatically +) +``` + +### Configure and Run the Server + +Set the required environment variables: + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini" +# Optional: Set API key if not using DefaultAzureCredential +# export AZURE_OPENAI_API_KEY="your-api-key" +``` + +Run the server: + +```bash +python server.py +``` + +Or using uvicorn directly: + +```bash +uvicorn server:app --host 127.0.0.1 --port 5100 +``` + +The server will start listening on `http://127.0.0.1:5100`. + +## Step 2: Creating an AG-UI Client + +The AG-UI client connects to the remote server and displays streaming responses. + +### Install Required Packages + +```bash +pip install httpx +``` + +### Client Code + +Create a file named `client.py`: + +```python +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI client example.""" + +import asyncio +import json +import os +from typing import AsyncIterator + +import httpx + + +class AGUIClient: + """Simple AG-UI protocol client.""" + + def __init__(self, server_url: str): + """Initialize the client. + + Args: + server_url: The AG-UI server endpoint URL + """ + self.server_url = server_url + self.thread_id: str | None = None + + async def send_message(self, message: str) -> AsyncIterator[dict]: + """Send a message and stream the response. + + Args: + message: The user message to send + + Yields: + AG-UI events from the server + """ + # Prepare the request + request_data = { + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": message}, + ] + } + + # Include thread_id if we have one (for conversation continuity) + if self.thread_id: + request_data["thread_id"] = self.thread_id + + # Stream the response + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + self.server_url, + json=request_data, + headers={"Accept": "text/event-stream"}, + ) as response: + response.raise_for_status() + + async for line in response.aiter_lines(): + # Parse Server-Sent Events format + if line.startswith("data: "): + data = line[6:] # Remove "data: " prefix + try: + event = json.loads(data) + yield event + + # Capture thread_id from RUN_STARTED event + if event.get("type") == "RUN_STARTED" and not self.thread_id: + self.thread_id = event.get("threadId") + except json.JSONDecodeError: + continue + + +async def main(): + """Main client loop.""" + # Get server URL from environment or use default + server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") + print(f"Connecting to AG-UI server at: {server_url}\n") + + client = AGUIClient(server_url) + + try: + while True: + # Get user input + message = input("\nUser (:q or quit to exit): ") + if not message.strip(): + print("Request cannot be empty.") + continue + + if message.lower() in (":q", "quit"): + break + + # Send message and display streaming response + print("\n", end="") + async for event in client.send_message(message): + event_type = event.get("type", "") + + if event_type == "RUN_STARTED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "TEXT_MESSAGE_CONTENT": + # Stream text content in cyan + print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True) + + elif event_type == "RUN_FINISHED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "RUN_ERROR": + error_message = event.get("message", "Unknown error") + print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m") + + print() + + except KeyboardInterrupt: + print("\n\nExiting...") + except Exception as e: + print(f"\n\033[91mAn error occurred: {e}\033[0m") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Key Concepts + +- **Server-Sent Events (SSE)**: The protocol uses SSE format (`data: {json}\n\n`) +- **Event Types**: Different events provide metadata and content (all event types use UPPERCASE with underscores): + - `RUN_STARTED`: Signals the agent has started processing + - `TEXT_MESSAGE_START`: Signals the start of a text message from the agent + - `TEXT_MESSAGE_CONTENT`: Incremental text streamed from the agent (with `delta` field) + - `TEXT_MESSAGE_END`: Signals the end of a text message + - `RUN_FINISHED`: Signals successful completion + - `RUN_ERROR`: Error information if something goes wrong +- **Field Naming**: Event fields use camelCase (e.g., `threadId`, `runId`, `messageId`) when accessing JSON events +- **Thread Management**: The `threadId` maintains conversation context across requests +- **Client-Side Instructions**: System messages are sent from the client + +### Configure and Run the Client + +Optionally set a custom server URL: + +```bash +export AGUI_SERVER_URL="http://127.0.0.1:5100/" +``` + +Run the client (in a separate terminal): + +```bash +python client.py +``` + +## Step 3: Testing the Complete System + +### Expected Output + +``` +$ python client.py +Connecting to AG-UI server at: http://127.0.0.1:5100/ + +User (:q or quit to exit): What is the capital of France? + +[Run Started - Thread: abc123, Run: xyz789] +The capital of France is Paris. It is known for its rich history, culture, +and iconic landmarks such as the Eiffel Tower and the Louvre Museum. +[Run Finished - Thread: abc123, Run: xyz789] + +User (:q or quit to exit): Tell me a fun fact about space + +[Run Started - Thread: abc123, Run: def456] +Here's a fun fact: A day on Venus is longer than its year! Venus takes +about 243 Earth days to rotate once on its axis, but only about 225 Earth +days to orbit the Sun. +[Run Finished - Thread: abc123, Run: def456] + +User (:q or quit to exit): :q +``` + +### Color-Coded Output + +The client displays different content types with distinct colors: +- **Yellow**: Run started notifications +- **Cyan**: Agent text responses (streamed in real-time) +- **Green**: Run completion notifications +- **Red**: Error messages + +## Testing with curl (Optional) + +Before running the client, you can test the server manually using curl: + +```bash +curl -N http://127.0.0.1:5100/ \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "messages": [ + {"role": "user", "content": "What is the capital of France?"} + ] + }' +``` + +You should see Server-Sent Events streaming back: + +``` +data: {"type":"RUN_STARTED","threadId":"...","runId":"..."} + +data: {"type":"TEXT_MESSAGE_START","messageId":"...","role":"assistant"} + +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":"The"} + +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":" capital"} + +... + +data: {"type":"TEXT_MESSAGE_END","messageId":"..."} + +data: {"type":"RUN_FINISHED","threadId":"...","runId":"..."} +``` + +## How It Works + +### Server-Side Flow + +1. Client sends HTTP POST request with messages +2. FastAPI endpoint receives the request +3. `AgentFrameworkAgent` wrapper orchestrates the execution +4. Agent processes the messages using Agent Framework +5. `AgentFrameworkEventBridge` converts agent updates to AG-UI events +6. Responses are streamed back as Server-Sent Events (SSE) +7. Connection closes when the run completes + +### Client-Side Flow + +1. Client sends HTTP POST request to server endpoint +2. Server responds with SSE stream +3. Client parses incoming `data:` lines as JSON events +4. Each event is displayed based on its type +5. `threadId` is captured for conversation continuity +6. Stream completes when `RUN_FINISHED` event arrives + +### Protocol Details + +The AG-UI protocol uses: +- **HTTP POST** for sending requests +- **Server-Sent Events (SSE)** for streaming responses +- **JSON** for event serialization +- **Thread IDs** for maintaining conversation context +- **Run IDs** for tracking individual executions +- **Event type naming**: UPPERCASE with underscores (e.g., `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`) +- **Field naming**: camelCase (e.g., `threadId`, `runId`, `messageId`) + +## Advanced Features + +The Python AG-UI implementation supports all 7 AG-UI features: + +### 1. Backend Tool Rendering + +Add tools to your agent for backend execution: + +```python +from typing import Any + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + + +@ai_function +def get_weather(location: str) -> dict[str, Any]: + """Get weather for a location.""" + return {"temperature": 72, "conditions": "sunny"} + + +agent = ChatAgent( + name="weather_agent", + instructions="Use tools to help users.", + chat_client=AzureOpenAIChatClient( + endpoint="https://your-resource.openai.azure.com/", + deployment_name="gpt-4o-mini", + ), + tools=[get_weather], +) +``` + +The client will receive `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, and `TOOL_CALL_RESULT` events. + +### 2. Human in the Loop + +Request user confirmation before executing tools: + +```python +from fastapi import FastAPI +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint + +agent = ChatAgent( + name="my_agent", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint="https://your-resource.openai.azure.com/", + deployment_name="gpt-4o-mini", + ), +) + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + require_confirmation=True, # Enable human-in-the-loop +) + +app = FastAPI() +add_agent_framework_fastapi_endpoint(app, wrapped_agent, "/") +``` + +The client receives tool approval request events and can send approval responses. + +### 3. State Management + +Share state between client and server: + +```python +wrapped_agent = AgentFrameworkAgent( + agent=agent, + state_schema={ + "location": {"type": "string"}, + "preferences": {"type": "object"}, + }, +) +``` + +Events include `STATE_SNAPSHOT` and `STATE_DELTA` for bidirectional sync. + +### 4. Predictive State Updates + +Stream tool arguments as optimistic state updates: + +```python +wrapped_agent = AgentFrameworkAgent( + agent=agent, + predict_state_config={ + "location": {"tool": "get_weather", "tool_argument": "location"} + }, + require_confirmation=False, # Auto-update without confirmation +) +``` + +State updates stream in real-time as the LLM generates tool arguments. + +## Common Patterns + +### Custom Server Configuration + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +app = FastAPI() + +# Add CORS for web clients +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +add_agent_framework_fastapi_endpoint(app, agent, "/agent") +``` + +### Multiple Agents + +```python +app = FastAPI() + +weather_agent = ChatAgent(name="weather", ...) +finance_agent = ChatAgent(name="finance", ...) + +add_agent_framework_fastapi_endpoint(app, weather_agent, "/weather") +add_agent_framework_fastapi_endpoint(app, finance_agent, "/finance") +``` + +### Custom Client Timeout + +```python +async with httpx.AsyncClient(timeout=300.0) as client: + async with client.stream("POST", server_url, ...) as response: + async for line in response.aiter_lines(): + # Process events + pass +``` + +### Error Handling + +```python +try: + async for event in client.send_message(message): + if event.get("type") == "RUN_ERROR": + error_msg = event.get("message", "Unknown error") + print(f"Error: {error_msg}") + # Handle error appropriately +except httpx.HTTPError as e: + print(f"HTTP error: {e}") +except Exception as e: + print(f"Unexpected error: {e}") +``` + +### Conversation Continuity + +The client automatically maintains `threadId` across requests: + +```python +client = AGUIClient(server_url) + +# First message +async for event in client.send_message("Hello"): + # Client captures threadId from RUN_STARTED + pass + +# Second message - uses same threadId +async for event in client.send_message("Continue our conversation"): + # Conversation context is maintained + pass +``` + +## AG-UI Event Reference + +### Core Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `RUN_STARTED` | Agent execution started | `threadId`, `runId` | +| `RUN_FINISHED` | Agent execution completed | `threadId`, `runId` | +| `RUN_ERROR` | Agent execution error | `message` | + +### Text Message Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `TEXT_MESSAGE_START` | Start of agent text message | `messageId`, `role` | +| `TEXT_MESSAGE_CONTENT` | Streaming text content | `messageId`, `delta` | +| `TEXT_MESSAGE_END` | End of agent text message | `messageId` | + +### Tool Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `TOOL_CALL_START` | Tool call initiated | `toolCallId`, `toolCallName` | +| `TOOL_CALL_ARGS` | Tool arguments streaming | `toolCallId`, `delta` | +| `TOOL_CALL_END` | Tool call complete | `toolCallId` | +| `TOOL_CALL_RESULT` | Tool execution result | `toolCallId`, `content` | + +### State Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `STATE_SNAPSHOT` | Complete state | `snapshot` | +| `STATE_DELTA` | State changes (JSON Patch) | `delta` | + +### Other Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `MESSAGES_SNAPSHOT` | Conversation history | `messages` | +| `CUSTOM` | Custom event data | `name`, `value` | + +## Next Steps + +Now that you understand the basics of AG-UI, you can: + +- **Add Tools**: Create custom `@ai_function` tools for your domain +- **Web Integration**: Build React/Vue frontends using the AG-UI protocol +- **State Management**: Implement shared state for generative UI applications +- **Human-in-the-Loop**: Add approval workflows for sensitive operations +- **Deployment**: Deploy to Azure Container Apps or Azure App Service +- **Multi-Agent Systems**: Coordinate multiple specialized agents +- **Monitoring**: Add logging and OpenTelemetry for observability + +## Additional Resources + +- [AG-UI Examples](../examples/README.md): Complete working examples for all 7 features +- [Agent Framework Documentation](../../core/README.md): Learn more about creating agents +- [AG-UI Protocol Spec](https://docs.ag-ui.com/): Official protocol documentation + +## Troubleshooting + +### Connection Refused + +Ensure the server is running before starting the client: + +```bash +# Terminal 1 +python server.py + +# Terminal 2 (after server starts) +python client.py +``` + +### Authentication Errors + +Make sure you're authenticated with Azure: + +```bash +az login +``` + +Verify you have the correct role assignment on the Azure OpenAI resource. + +### Streaming Not Working + +Check that your client timeout is sufficient: + +```python +httpx.AsyncClient(timeout=60.0) # 60 seconds should be enough +``` + +For long-running agents, increase the timeout accordingly. + +### No Events Received + +Ensure you're using the correct `Accept` header: + +```python +headers={"Accept": "text/event-stream"} +``` + +And parsing SSE format correctly (lines starting with `data: `). + +### Thread Context Lost + +The client automatically manages thread continuity. If context is lost: + +1. Check that `threadId` is being captured from `RUN_STARTED` events +2. Ensure the same client instance is used across messages +3. Verify the server is receiving the `thread_id` in subsequent requests + +### Event Type Mismatches + +Remember that event types are UPPERCASE with underscores (`RUN_STARTED`, not `run_started`) and field names are camelCase (`threadId`, not `thread_id`). + +### Import Errors + +Make sure all packages are installed: + +```bash +pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn httpx +``` + +Or check your virtual environment is activated: + +```bash +source venv/bin/activate # Linux/macOS +venv\Scripts\activate # Windows +``` diff --git a/python/packages/ag-ui/getting_started/client.py b/python/packages/ag-ui/getting_started/client.py new file mode 100644 index 0000000000..82d3d1358e --- /dev/null +++ b/python/packages/ag-ui/getting_started/client.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI client example.""" + +import asyncio +import json +import os +from collections.abc import AsyncIterator + +import httpx + + +class AGUIClient: + """Simple AG-UI protocol client.""" + + def __init__(self, server_url: str): + """Initialize the client. + + Args: + server_url: The AG-UI server endpoint URL + """ + self.server_url = server_url + self.thread_id: str | None = None + + async def send_message(self, message: str) -> AsyncIterator[dict]: + """Send a message and stream the response. + + Args: + message: The user message to send + + Yields: + AG-UI events from the server + """ + # Prepare the request + request_data: dict[str, object] = { + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": message}, + ] + } + + # Include thread_id if we have one (for conversation continuity) + if self.thread_id: + request_data["thread_id"] = self.thread_id + + # Stream the response + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + self.server_url, + json=request_data, + headers={"Accept": "text/event-stream"}, + ) as response: + response.raise_for_status() + + async for line in response.aiter_lines(): + # Parse Server-Sent Events format + if line.startswith("data: "): + data = line[6:] # Remove "data: " prefix + try: + event = json.loads(data) + yield event + + # Capture thread_id from RUN_STARTED event + if event.get("type") == "RUN_STARTED" and not self.thread_id: + self.thread_id = event.get("threadId") + except json.JSONDecodeError: + continue + + +async def main(): + """Main client loop.""" + # Get server URL from environment or use default + server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") + print(f"Connecting to AG-UI server at: {server_url}\n") + + client = AGUIClient(server_url) + + try: + while True: + # Get user input + message = input("\nUser (:q or quit to exit): ") + if not message.strip(): + print("Request cannot be empty.") + continue + + if message.lower() in (":q", "quit"): + break + + # Send message and display streaming response + print("\n", end="") + async for event in client.send_message(message): + event_type = event.get("type", "") + + if event_type == "RUN_STARTED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "TEXT_MESSAGE_CONTENT": + # Stream text content in cyan + print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True) + + elif event_type == "RUN_FINISHED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "RUN_ERROR": + error_message = event.get("message", "Unknown error") + print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m") + + print() + + except KeyboardInterrupt: + print("\n\nExiting...") + except Exception as e: + print(f"\n\033[91mAn error occurred: {e}\033[0m") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/ag-ui/getting_started/server.py b/python/packages/ag-ui/getting_started/server.py new file mode 100644 index 0000000000..34e2edbd5f --- /dev/null +++ b/python/packages/ag-ui/getting_started/server.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI server example.""" + +import os + +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from dotenv import load_dotenv +from fastapi import FastAPI + +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +load_dotenv() + +# Read required configuration +endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") +deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") + +if not endpoint: + raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") +if not deployment_name: + raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required") + +# Create the AI agent +agent = ChatAgent( + name="AGUIAssistant", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint=endpoint, + deployment_name=deployment_name, + ), +) + +# Create FastAPI app +app = FastAPI(title="AG-UI Server") + +# Register the AG-UI endpoint +add_agent_framework_fastapi_endpoint(app, agent, "/") + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=5100) diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml new file mode 100644 index 0000000000..019d4705f2 --- /dev/null +++ b/python/packages/ag-ui/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "agent-framework-ag-ui" +version = "0.1.0" +description = "AG-UI protocol integration for Agent Framework" +readme = "README.md" +license = { file = "LICENSE" } +authors = [ + { name = "Microsoft", email = "agent-framework@microsoft.com" } +] +requires-python = ">=3.10" +dependencies = [ + "agent-framework-core", + "ag-ui-protocol>=0.1.9", + "fastapi>=0.115.0", + "uvicorn>=0.30.0" +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "httpx>=0.27.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["agent_framework_ag_ui"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pyright] +exclude = ["tests", "examples"] +typeCheckingMode = "basic" + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui" +test = "pytest --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/ag-ui/tests/__init__.py b/python/packages/ag-ui/tests/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/ag-ui/tests/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py new file mode 100644 index 0000000000..723e369c43 --- /dev/null +++ b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py @@ -0,0 +1,577 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for AgentFrameworkAgent (_agent.py).""" + +import json + +import pytest +from agent_framework import ChatAgent, TextContent +from agent_framework._types import ChatResponseUpdate + + +async def test_agent_initialization_basic(): + """Test basic agent initialization without state schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + assert wrapper.name == "test_agent" + assert wrapper.agent == agent + assert wrapper.config.state_schema == {} + assert wrapper.config.predict_state_config == {} + + +async def test_agent_initialization_with_state_schema(): + """Test agent initialization with state_schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"document": {"type": "string"}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + assert wrapper.config.state_schema == state_schema + + +async def test_agent_initialization_with_predict_state_config(): + """Test agent initialization with predict_state_config.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} + wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config) + + assert wrapper.config.predict_state_config == predict_config + + +async def test_run_started_event_emission(): + """Test RunStartedEvent is emitted at start of run.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # First event should be RunStartedEvent + assert events[0].type == "RUN_STARTED" + assert events[0].run_id is not None + assert events[0].thread_id is not None + + +async def test_predict_state_custom_event_emission(): + """Test PredictState CustomEvent is emitted when predict_state_config is present.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + predict_config = { + "document": {"tool": "write_doc", "tool_argument": "content"}, + "summary": {"tool": "summarize", "tool_argument": "text"}, + } + wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find PredictState event + predict_events = [e for e in events if e.type == "CUSTOM" and e.name == "PredictState"] + assert len(predict_events) == 1 + + predict_value = predict_events[0].value + assert len(predict_value) == 2 + assert {"state_key": "document", "tool": "write_doc", "tool_argument": "content"} in predict_value + assert {"state_key": "summary", "tool": "summarize", "tool_argument": "text"} in predict_value + + +async def test_initial_state_snapshot_with_schema(): + """Test initial StateSnapshotEvent emission when state_schema present.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"document": {"type": "string"}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + input_data = { + "messages": [{"role": "user", "content": "Hi"}], + "state": {"document": "Initial content"}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find StateSnapshotEvent + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # First snapshot should have initial state + assert snapshot_events[0].snapshot == {"document": "Initial content"} + + +async def test_state_initialization_object_type(): + """Test state initialization with object type in schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"recipe": {"type": "object", "properties": {}}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find StateSnapshotEvent + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # Should initialize as empty object + assert snapshot_events[0].snapshot == {"recipe": {}} + + +async def test_state_initialization_array_type(): + """Test state initialization with array type in schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"steps": {"type": "array", "items": {}}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find StateSnapshotEvent + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # Should initialize as empty array + assert snapshot_events[0].snapshot == {"steps": []} + + +async def test_run_finished_event_emission(): + """Test RunFinishedEvent is emitted at end of run.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Last event should be RunFinishedEvent + assert events[-1].type == "RUN_FINISHED" + + +async def test_tool_result_confirm_changes_accepted(): + """Test confirm_changes tool result handling when accepted.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Document updated")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"document": {"type": "string"}}, + predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}, + ) + + # Simulate tool result message with acceptance + tool_result = {"accepted": True, "steps": []} + input_data = { + "messages": [ + { + "role": "tool", # Tool result from UI + "content": json.dumps(tool_result), + "toolCallId": "confirm_call_123", + } + ], + "state": {"document": "Updated content"}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit text message confirming acceptance + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + # Should contain confirmation message mentioning the state key or generic confirmation + confirmation_found = any( + "document" in e.delta.lower() + or "confirm" in e.delta.lower() + or "applied" in e.delta.lower() + or "changes" in e.delta.lower() + for e in text_content_events + ) + assert confirmation_found, f"No confirmation in deltas: {[e.delta for e in text_content_events]}" + + +async def test_tool_result_confirm_changes_rejected(): + """Test confirm_changes tool result handling when rejected.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Simulate tool result message with rejection + tool_result = {"accepted": False, "steps": []} + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "confirm_call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit text message asking what to change + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + assert any("what would you like me to change" in e.delta.lower() for e in text_content_events) + + +async def test_tool_result_function_approval_accepted(): + """Test function approval tool result when steps are accepted.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Simulate tool result with multiple steps + tool_result = { + "accepted": True, + "steps": [ + {"id": "step1", "description": "Send email", "status": "enabled"}, + {"id": "step2", "description": "Create calendar event", "status": "enabled"}, + ], + } + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "approval_call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should list enabled steps + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + + # Concatenate all text content + full_text = "".join(e.delta for e in text_content_events) + assert "executing" in full_text.lower() + assert "2 approved steps" in full_text.lower() + assert "send email" in full_text.lower() + assert "create calendar event" in full_text.lower() + + +async def test_tool_result_function_approval_rejected(): + """Test function approval tool result when rejected.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Simulate tool result rejection with steps + tool_result = { + "accepted": False, + "steps": [{"id": "step1", "description": "Send email", "status": "disabled"}], + } + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "approval_call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should ask what to change about the plan + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + assert any("what would you like me to change about the plan" in e.delta.lower() for e in text_content_events) + + +async def test_thread_metadata_tracking(): + """Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + thread_metadata = {} + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Capture thread metadata from kwargs + nonlocal thread_metadata + if "thread" in kwargs: + thread_metadata = kwargs["thread"].metadata + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = { + "messages": [{"role": "user", "content": "Hi"}], + "thread_id": "test_thread_123", + "run_id": "test_run_456", + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Check thread metadata was set + # Note: This test may need adjustment based on actual thread passing mechanism + + +async def test_state_context_injection(): + """Test that current state is injected into thread metadata.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + thread_metadata = {} + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Track if state context message was added + nonlocal thread_metadata + # In actual implementation, thread is passed and state is in metadata + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"document": {"type": "string"}}, + ) + + input_data = { + "messages": [{"role": "user", "content": "Hi"}], + "state": {"document": "Test content"}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # State should be injected - this is validated by agent execution flow + + +async def test_no_messages_provided(): + """Test handling when no messages are provided.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": []} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit RunStartedEvent and RunFinishedEvent only + assert len(events) == 2 + assert events[0].type == "RUN_STARTED" + assert events[-1].type == "RUN_FINISHED" + + +async def test_message_end_event_emission(): + """Test TextMessageEndEvent is emitted for assistant messages.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello world")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should have TextMessageEndEvent before RunFinishedEvent + end_events = [e for e in events if e.type == "TEXT_MESSAGE_END"] + assert len(end_events) == 1 + + # EndEvent should come before FinishedEvent + end_index = events.index(end_events[0]) + finished_index = events.index([e for e in events if e.type == "RUN_FINISHED"][0]) + assert end_index < finished_index + + +async def test_error_handling_with_exception(): + """Test that exceptions during agent execution are re-raised.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class FailingChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + if False: + yield + raise RuntimeError("Simulated failure") + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=FailingChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + with pytest.raises(RuntimeError, match="Simulated failure"): + async for event in wrapper.run_agent(input_data): + pass + + +async def test_json_decode_error_in_tool_result(): + """Test handling of JSONDecodeError when parsing tool result.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Fallback response")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Send invalid JSON as tool result + input_data = { + "messages": [ + { + "role": "tool", + "content": "invalid json {not valid}", + "toolCallId": "call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should fall through to normal agent processing + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_events) > 0 + assert text_events[0].delta == "Fallback response" + + +async def test_suppressed_summary_with_document_state(): + """Test suppressed summary uses document state for confirmation message.""" + from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Response")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"document": {"type": "string"}}, + predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}, + confirmation_strategy=DocumentWriterConfirmationStrategy(), + ) + + # Simulate confirmation with document state + tool_result = {"accepted": True, "steps": []} + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "confirm_123", + } + ], + "state": {"document": "This is the beginning of a document. It contains important information."}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should generate fallback summary from document state + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_events) > 0 + # Should contain some reference to the document + full_text = "".join(e.delta for e in text_events) + assert "written" in full_text.lower() or "document" in full_text.lower() diff --git a/python/packages/ag-ui/tests/test_backend_tool_rendering.py b/python/packages/ag-ui/tests/test_backend_tool_rendering.py new file mode 100644 index 0000000000..fbd27ee8bb --- /dev/null +++ b/python/packages/ag-ui/tests/test_backend_tool_rendering.py @@ -0,0 +1,124 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for backend tool rendering.""" + +from ag_ui.core import ( + TextMessageContentEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from agent_framework import AgentRunResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent + +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +async def test_tool_call_flow(): + """Test complete tool call flow: call -> args -> end -> result.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # Step 1: Tool call starts + tool_call = FunctionCallContent( + call_id="weather-123", + name="get_weather", + arguments={"location": "Seattle"}, + ) + + update1 = AgentRunResponseUpdate(contents=[tool_call]) + events1 = await bridge.from_agent_run_update(update1) + + # Should have: ToolCallStartEvent, ToolCallArgsEvent + assert len(events1) == 2 + assert isinstance(events1[0], ToolCallStartEvent) + assert isinstance(events1[1], ToolCallArgsEvent) + + start_event = events1[0] + assert start_event.tool_call_id == "weather-123" + assert start_event.tool_call_name == "get_weather" + + args_event = events1[1] + assert "Seattle" in args_event.delta + + # Step 2: Tool result comes back + tool_result = FunctionResultContent( + call_id="weather-123", + result="Weather in Seattle: Rainy, 52°F", + ) + + update2 = AgentRunResponseUpdate(contents=[tool_result]) + events2 = await bridge.from_agent_run_update(update2) + + # Should have: ToolCallEndEvent, ToolCallResultEvent, MessagesSnapshotEvent + assert len(events2) == 3 + assert isinstance(events2[0], ToolCallEndEvent) + assert isinstance(events2[1], ToolCallResultEvent) + + end_event = events2[0] + assert end_event.tool_call_id == "weather-123" + + result_event = events2[1] + assert result_event.tool_call_id == "weather-123" + assert "Seattle" in result_event.content + assert "Rainy" in result_event.content + + +async def test_text_with_tool_call(): + """Test agent response with both text and tool calls.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # Agent says something then calls a tool + text_content = TextContent(text="Let me check the weather for you.") + tool_call = FunctionCallContent( + call_id="weather-456", + name="get_forecast", + arguments={"location": "San Francisco", "days": 3}, + ) + + update = AgentRunResponseUpdate(contents=[text_content, tool_call]) + events = await bridge.from_agent_run_update(update) + + # Should have: TextMessageStart, TextMessageContent, ToolCallStart, ToolCallArgs + assert len(events) == 4 + + assert isinstance(events[0], TextMessageStartEvent) + assert isinstance(events[1], TextMessageContentEvent) + assert isinstance(events[2], ToolCallStartEvent) + assert isinstance(events[3], ToolCallArgsEvent) + + text_event = events[1] + assert "check the weather" in text_event.delta + + tool_start = events[2] + assert tool_start.tool_call_name == "get_forecast" + + +async def test_multiple_tool_results(): + """Test handling multiple tool results in sequence.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # Multiple tool results + results = [ + FunctionResultContent(call_id="tool-1", result="Result 1"), + FunctionResultContent(call_id="tool-2", result="Result 2"), + FunctionResultContent(call_id="tool-3", result="Result 3"), + ] + + update = AgentRunResponseUpdate(contents=results) + events = await bridge.from_agent_run_update(update) + + # Should have 3 pairs of ToolCallEndEvent + ToolCallResultEvent = 6 events + assert len(events) == 6 + + # Verify the pattern: End, Result, End, Result, End, Result + for i in range(3): + end_idx = i * 2 + result_idx = i * 2 + 1 + + assert isinstance(events[end_idx], ToolCallEndEvent) + assert isinstance(events[result_idx], ToolCallResultEvent) + + assert events[end_idx].tool_call_id == f"tool-{i + 1}" + assert events[result_idx].tool_call_id == f"tool-{i + 1}" + assert f"Result {i + 1}" in events[result_idx].content diff --git a/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py b/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py new file mode 100644 index 0000000000..205182d58d --- /dev/null +++ b/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for all confirmation strategies.""" + +import pytest + +from agent_framework_ag_ui._confirmation_strategies import ( + ConfirmationStrategy, + DefaultConfirmationStrategy, + DocumentWriterConfirmationStrategy, + RecipeConfirmationStrategy, + TaskPlannerConfirmationStrategy, +) + + +@pytest.fixture +def sample_steps(): + """Sample steps for testing approval messages.""" + return [ + {"description": "Step 1: Do something", "status": "enabled"}, + {"description": "Step 2: Do another thing", "status": "enabled"}, + {"description": "Step 3: Disabled step", "status": "disabled"}, + ] + + +@pytest.fixture +def all_enabled_steps(): + """All steps enabled.""" + return [ + {"description": "Task A", "status": "enabled"}, + {"description": "Task B", "status": "enabled"}, + {"description": "Task C", "status": "enabled"}, + ] + + +@pytest.fixture +def empty_steps(): + """Empty steps list.""" + return [] + + +class TestDefaultConfirmationStrategy: + """Tests for DefaultConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Executing 2 approved steps" in message + assert "Step 1: Do something" in message + assert "Step 2: Do another thing" in message + assert "Step 3" not in message # Disabled step shouldn't appear + assert "All steps completed successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Executing 3 approved steps" in message + assert "Task A" in message + assert "Task B" in message + assert "Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Executing 0 approved steps" in message + assert "All steps completed successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "What would you like me to change" in message + + def test_on_state_confirmed(self): + strategy = DefaultConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Changes confirmed" in message + assert "successfully" in message + + def test_on_state_rejected(self): + strategy = DefaultConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "What would you like me to change" in message + + +class TestTaskPlannerConfirmationStrategy: + """Tests for TaskPlannerConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Executing your requested tasks" in message + assert "1. Step 1: Do something" in message + assert "2. Step 2: Do another thing" in message + assert "Step 3" not in message + assert "All tasks completed successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Executing your requested tasks" in message + assert "1. Task A" in message + assert "2. Task B" in message + assert "3. Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Executing your requested tasks" in message + assert "All tasks completed successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "revise the plan" in message + + def test_on_state_confirmed(self): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Tasks confirmed" in message + assert "ready to execute" in message + + def test_on_state_rejected(self): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "adjust the task list" in message + + +class TestRecipeConfirmationStrategy: + """Tests for RecipeConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Updating your recipe" in message + assert "1. Step 1: Do something" in message + assert "2. Step 2: Do another thing" in message + assert "Step 3" not in message + assert "Recipe updated successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Updating your recipe" in message + assert "1. Task A" in message + assert "2. Task B" in message + assert "3. Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Updating your recipe" in message + assert "Recipe updated successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "ingredients or steps" in message + + def test_on_state_confirmed(self): + strategy = RecipeConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Recipe changes applied" in message + assert "successfully" in message + + def test_on_state_rejected(self): + strategy = RecipeConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "adjust in the recipe" in message + + +class TestDocumentWriterConfirmationStrategy: + """Tests for DocumentWriterConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Applying your edits" in message + assert "1. Step 1: Do something" in message + assert "2. Step 2: Do another thing" in message + assert "Step 3" not in message + assert "Document updated successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Applying your edits" in message + assert "1. Task A" in message + assert "2. Task B" in message + assert "3. Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Applying your edits" in message + assert "Document updated successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "keep or modify" in message + + def test_on_state_confirmed(self): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Document edits applied!" in message + + def test_on_state_rejected(self): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "change about the document" in message + + +class TestConfirmationStrategyInterface: + """Tests for ConfirmationStrategy abstract base class.""" + + def test_cannot_instantiate_abstract_class(self): + """Verify ConfirmationStrategy is abstract and cannot be instantiated.""" + with pytest.raises(TypeError): + ConfirmationStrategy() # type: ignore + + def test_all_strategies_implement_interface(self): + """Verify all concrete strategies implement the full interface.""" + strategies = [ + DefaultConfirmationStrategy(), + TaskPlannerConfirmationStrategy(), + RecipeConfirmationStrategy(), + DocumentWriterConfirmationStrategy(), + ] + + sample_steps = [{"description": "Test", "status": "enabled"}] + + for strategy in strategies: + # All should have these methods + assert callable(strategy.on_approval_accepted) + assert callable(strategy.on_approval_rejected) + assert callable(strategy.on_state_confirmed) + assert callable(strategy.on_state_rejected) + + # All should return strings + assert isinstance(strategy.on_approval_accepted(sample_steps), str) + assert isinstance(strategy.on_approval_rejected(sample_steps), str) + assert isinstance(strategy.on_state_confirmed(), str) + assert isinstance(strategy.on_state_rejected(), str) diff --git a/python/packages/ag-ui/tests/test_document_writer_flow.py b/python/packages/ag-ui/tests/test_document_writer_flow.py new file mode 100644 index 0000000000..d46b9bf7a0 --- /dev/null +++ b/python/packages/ag-ui/tests/test_document_writer_flow.py @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for document writer predictive state flow with confirm_changes.""" + +from ag_ui.core import EventType +from agent_framework import FunctionCallContent, FunctionResultContent, TextContent +from agent_framework._types import AgentRunResponseUpdate + +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +async def test_streaming_document_with_state_deltas(): + """Test that streaming tool arguments emit progressive StateDeltaEvents.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # Simulate streaming tool call - first chunk with name + tool_call_start = FunctionCallContent( + call_id="call_123", + name="write_document_local", + arguments='{"document":"Once', + ) + update1 = AgentRunResponseUpdate(contents=[tool_call_start]) + events1 = await bridge.from_agent_run_update(update1) + + # Should have ToolCallStartEvent and ToolCallArgsEvent + assert any(e.type == EventType.TOOL_CALL_START for e in events1) + assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1) + + # Second chunk - incomplete JSON, should try partial extraction + tool_call_chunk2 = FunctionCallContent( + call_id="call_123", + name=None, # Name only in first chunk + arguments=" upon a time", + ) + update2 = AgentRunResponseUpdate(contents=[tool_call_chunk2]) + events2 = await bridge.from_agent_run_update(update2) + + # Should emit StateDeltaEvent with partial document + state_deltas = [e for e in events2 if e.type == EventType.STATE_DELTA] + assert len(state_deltas) >= 1 + + # Check JSON Patch format + delta = state_deltas[0] + assert isinstance(delta.delta, list) + assert len(delta.delta) > 0 + assert delta.delta[0]["op"] == "replace" + assert delta.delta[0]["path"] == "/document" + assert "Once upon a time" in delta.delta[0]["value"] + + +async def test_confirm_changes_emission(): + """Test that confirm_changes tool call is emitted after predictive tool completion.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + current_state = {} + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + current_state=current_state, + ) + + # Set current tool name (simulating earlier tool call start) + bridge.current_tool_call_name = "write_document_local" + bridge.pending_state_updates = {"document": "A short story"} + + # Tool result + tool_result = FunctionResultContent( + call_id="call_123", + result="Document written.", + ) + + update = AgentRunResponseUpdate(contents=[tool_result]) + events = await bridge.from_agent_run_update(update) + + # Should have: ToolCallEndEvent, ToolCallResultEvent, StateSnapshotEvent, confirm_changes sequence + assert any(e.type == EventType.TOOL_CALL_END for e in events) + assert any(e.type == EventType.TOOL_CALL_RESULT for e in events) + assert any(e.type == EventType.STATE_SNAPSHOT for e in events) + + # Check for confirm_changes tool call + confirm_starts = [ + e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes" + ] + assert len(confirm_starts) == 1 + + confirm_args = [e for e in events if e.type == EventType.TOOL_CALL_ARGS and e.delta == "{}"] + assert len(confirm_args) >= 1 + + confirm_ends = [e for e in events if e.type == EventType.TOOL_CALL_END] + # At least 2: one for write_document_local, one for confirm_changes + assert len(confirm_ends) >= 2 + + # Check that stop flag is set + assert bridge.should_stop_after_confirm is True + + +async def test_text_suppression_before_confirm(): + """Test that text messages are suppressed when confirm_changes is pending.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # Set flag indicating we're waiting for confirmation + bridge.should_stop_after_confirm = True + + # Text content that should be suppressed + text = TextContent(text="I have written a story about pirates.") + update = AgentRunResponseUpdate(contents=[text]) + + events = await bridge.from_agent_run_update(update) + + # Should NOT emit TextMessageContentEvent + text_events = [e for e in events if e.type == EventType.TEXT_MESSAGE_CONTENT] + assert len(text_events) == 0 + + # But should save the text + assert bridge.suppressed_summary == "I have written a story about pirates." + + +async def test_no_confirm_for_non_predictive_tools(): + """Test that confirm_changes is NOT emitted for regular tool calls.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + current_state = {} + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + current_state=current_state, + ) + + # Different tool (not in predict_state_config) + bridge.current_tool_call_name = "get_weather" + + tool_result = FunctionResultContent( + call_id="call_456", + result="Sunny, 72°F", + ) + + update = AgentRunResponseUpdate(contents=[tool_result]) + events = await bridge.from_agent_run_update(update) + + # Should NOT have confirm_changes + confirm_starts = [ + e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes" + ] + assert len(confirm_starts) == 0 + + # Stop flag should NOT be set + assert bridge.should_stop_after_confirm is False + + +async def test_state_delta_deduplication(): + """Test that duplicate state values don't emit multiple StateDeltaEvents.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # First tool call with document + tool_call1 = FunctionCallContent( + call_id="call_1", + name="write_document_local", + arguments='{"document":"Same text"}', + ) + update1 = AgentRunResponseUpdate(contents=[tool_call1]) + events1 = await bridge.from_agent_run_update(update1) + + # Count state deltas + state_deltas_1 = [e for e in events1 if e.type == EventType.STATE_DELTA] + assert len(state_deltas_1) >= 1 + + # Second tool call with SAME document (shouldn't emit new delta) + bridge.current_tool_call_name = "write_document_local" + tool_call2 = FunctionCallContent( + call_id="call_2", + name=None, + arguments='{"document":"Same text"}', # Identical content + ) + update2 = AgentRunResponseUpdate(contents=[tool_call2]) + events2 = await bridge.from_agent_run_update(update2) + + # Should NOT emit state delta (same value) + state_deltas_2 = [e for e in events2 if e.type == EventType.STATE_DELTA] + assert len(state_deltas_2) == 0 + + +async def test_predict_state_config_multiple_fields(): + """Test predictive state with multiple state fields.""" + predict_config = { + "title": {"tool": "create_post", "tool_argument": "title"}, + "content": {"tool": "create_post", "tool_argument": "body"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # Tool call with both fields + tool_call = FunctionCallContent( + call_id="call_999", + name="create_post", + arguments='{"title":"My Post","body":"Post content"}', + ) + update = AgentRunResponseUpdate(contents=[tool_call]) + events = await bridge.from_agent_run_update(update) + + # Should emit StateDeltaEvent for both fields + state_deltas = [e for e in events if e.type == EventType.STATE_DELTA] + assert len(state_deltas) >= 2 + + # Check both fields are present + paths = [delta.delta[0]["path"] for delta in state_deltas] + assert "/title" in paths + assert "/content" in paths diff --git a/python/packages/ag-ui/tests/test_endpoint.py b/python/packages/ag-ui/tests/test_endpoint.py new file mode 100644 index 0000000000..b5846bbbf8 --- /dev/null +++ b/python/packages/ag-ui/tests/test_endpoint.py @@ -0,0 +1,242 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for FastAPI endpoint creation (_endpoint.py).""" + +import json +from typing import Any + +from agent_framework import ChatAgent, TextContent +from agent_framework._types import ChatResponseUpdate +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agent_framework_ag_ui._agent import AgentFrameworkAgent +from agent_framework_ag_ui._endpoint import add_agent_framework_fastapi_endpoint + + +class MockChatClient: + """Mock chat client for testing.""" + + def __init__(self, response_text: str = "Test response"): + self.response_text = response_text + + async def get_streaming_response(self, messages: list[Any], chat_options: Any, **kwargs: Any): + """Mock streaming response.""" + yield ChatResponseUpdate(contents=[TextContent(text=self.response_text)]) + + +async def test_add_endpoint_with_agent_protocol(): + """Test adding endpoint with raw AgentProtocol.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/test-agent") + + client = TestClient(app) + response = client.post("/test-agent", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" + + +async def test_add_endpoint_with_wrapped_agent(): + """Test adding endpoint with pre-wrapped AgentFrameworkAgent.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + wrapped_agent = AgentFrameworkAgent(agent=agent, name="wrapped") + + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/wrapped-agent") + + client = TestClient(app) + response = client.post("/wrapped-agent", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" + + +async def test_endpoint_with_state_schema(): + """Test endpoint with state_schema parameter.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + state_schema = {"document": {"type": "string"}} + + add_agent_framework_fastapi_endpoint(app, agent, path="/stateful", state_schema=state_schema) + + client = TestClient(app) + response = client.post( + "/stateful", json={"messages": [{"role": "user", "content": "Hello"}], "state": {"document": ""}} + ) + + assert response.status_code == 200 + + +async def test_endpoint_with_predict_state_config(): + """Test endpoint with predict_state_config parameter.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} + + add_agent_framework_fastapi_endpoint(app, agent, path="/predictive", predict_state_config=predict_config) + + client = TestClient(app) + response = client.post("/predictive", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + + +async def test_endpoint_request_logging(): + """Test that endpoint logs request details.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/logged") + + client = TestClient(app) + response = client.post( + "/logged", + json={ + "messages": [{"role": "user", "content": "Test"}], + "run_id": "run-123", + "thread_id": "thread-456", + }, + ) + + assert response.status_code == 200 + + +async def test_endpoint_event_streaming(): + """Test that endpoint streams events correctly.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient("Streamed response")) + + add_agent_framework_fastapi_endpoint(app, agent, path="/stream") + + client = TestClient(app) + response = client.post("/stream", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + + content = response.content.decode("utf-8") + lines = [line for line in content.split("\n") if line.strip()] + + found_run_started = False + found_text_content = False + found_run_finished = False + + for line in lines: + if line.startswith("data: "): + event_data = json.loads(line[6:]) + if event_data.get("type") == "RUN_STARTED": + found_run_started = True + elif event_data.get("type") == "TEXT_MESSAGE_CONTENT": + found_text_content = True + elif event_data.get("type") == "RUN_FINISHED": + found_run_finished = True + + assert found_run_started + assert found_text_content + assert found_run_finished + + +async def test_endpoint_error_handling(): + """Test endpoint error handling during request parsing.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/failing") + + client = TestClient(app) + + # Send invalid JSON to trigger parsing error before streaming + response = client.post("/failing", data="invalid json", headers={"content-type": "application/json"}) + + # The exception handler catches it and returns JSON error + assert response.status_code == 200 + content = json.loads(response.content) + assert "error" in content + assert "Expecting value" in content["error"] + + +async def test_endpoint_multiple_paths(): + """Test adding multiple endpoints with different paths.""" + app = FastAPI() + agent1 = ChatAgent(name="agent1", instructions="First agent", chat_client=MockChatClient("Response 1")) + agent2 = ChatAgent(name="agent2", instructions="Second agent", chat_client=MockChatClient("Response 2")) + + add_agent_framework_fastapi_endpoint(app, agent1, path="/agent1") + add_agent_framework_fastapi_endpoint(app, agent2, path="/agent2") + + client = TestClient(app) + + response1 = client.post("/agent1", json={"messages": [{"role": "user", "content": "Hi"}]}) + response2 = client.post("/agent2", json={"messages": [{"role": "user", "content": "Hi"}]}) + + assert response1.status_code == 200 + assert response2.status_code == 200 + + +async def test_endpoint_default_path(): + """Test endpoint with default path.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent) + + client = TestClient(app) + response = client.post("/", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + + +async def test_endpoint_response_headers(): + """Test that endpoint sets correct response headers.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/headers") + + client = TestClient(app) + response = client.post("/headers", json={"messages": [{"role": "user", "content": "Test"}]}) + + assert response.status_code == 200 + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" + assert "cache-control" in response.headers + assert response.headers["cache-control"] == "no-cache" + + +async def test_endpoint_empty_messages(): + """Test endpoint with empty messages list.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/empty") + + client = TestClient(app) + response = client.post("/empty", json={"messages": []}) + + assert response.status_code == 200 + + +async def test_endpoint_complex_input(): + """Test endpoint with complex input data.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/complex") + + client = TestClient(app) + response = client.post( + "/complex", + json={ + "messages": [ + {"role": "user", "content": "First message", "id": "msg-1"}, + {"role": "assistant", "content": "Response", "id": "msg-2"}, + {"role": "user", "content": "Follow-up", "id": "msg-3"}, + ], + "run_id": "complex-run-123", + "thread_id": "complex-thread-456", + "state": {"custom_field": "value"}, + }, + ) + + assert response.status_code == 200 diff --git a/python/packages/ag-ui/tests/test_events_comprehensive.py b/python/packages/ag-ui/tests/test_events_comprehensive.py new file mode 100644 index 0000000000..cd2663bd3c --- /dev/null +++ b/python/packages/ag-ui/tests/test_events_comprehensive.py @@ -0,0 +1,659 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for AgentFrameworkEventBridge (_events.py).""" + +import json + +from agent_framework import ( + AgentRunResponseUpdate, + FunctionApprovalRequestContent, + FunctionCallContent, + FunctionResultContent, + TextContent, +) + + +async def test_basic_text_message_conversion(): + """Test basic TextContent to AG-UI events.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[TextContent(text="Hello")]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TEXT_MESSAGE_START" + assert events[0].role == "assistant" + assert events[1].type == "TEXT_MESSAGE_CONTENT" + assert events[1].delta == "Hello" + + +async def test_text_message_streaming(): + """Test streaming TextContent with multiple chunks.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update1 = AgentRunResponseUpdate(contents=[TextContent(text="Hello ")]) + update2 = AgentRunResponseUpdate(contents=[TextContent(text="world")]) + + events1 = await bridge.from_agent_run_update(update1) + events2 = await bridge.from_agent_run_update(update2) + + # First update: START + CONTENT + assert len(events1) == 2 + assert events1[0].type == "TEXT_MESSAGE_START" + assert events1[1].delta == "Hello " + + # Second update: just CONTENT (same message) + assert len(events2) == 1 + assert events2[0].type == "TEXT_MESSAGE_CONTENT" + assert events2[0].delta == "world" + + # Both content events should have same message_id + assert events1[1].message_id == events2[0].message_id + + +async def test_skip_text_content_for_structured_outputs(): + """Test that text content is skipped when skip_text_content=True.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", skip_text_content=True) + + update = AgentRunResponseUpdate(contents=[TextContent(text='{"result": "data"}')]) + events = await bridge.from_agent_run_update(update) + + # No events should be emitted + assert len(events) == 0 + + +async def test_tool_call_with_name(): + """Test FunctionCallContent with name emits ToolCallStartEvent.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 1 + assert events[0].type == "TOOL_CALL_START" + assert events[0].tool_call_name == "search_web" + assert events[0].tool_call_id == "call_123" + + +async def test_tool_call_streaming_args(): + """Test streaming tool call arguments.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # First chunk: name only + update1 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")]) + events1 = await bridge.from_agent_run_update(update1) + + # Second chunk: arguments chunk 1 (name can be empty string for continuation) + update2 = AgentRunResponseUpdate( + contents=[FunctionCallContent(name="", call_id="call_123", arguments='{"query": "')] + ) + events2 = await bridge.from_agent_run_update(update2) + + # Third chunk: arguments chunk 2 + update3 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='AI"}')]) + events3 = await bridge.from_agent_run_update(update3) + + # First update: ToolCallStartEvent + assert len(events1) == 1 + assert events1[0].type == "TOOL_CALL_START" + + # Second update: ToolCallArgsEvent + assert len(events2) == 1 + assert events2[0].type == "TOOL_CALL_ARGS" + assert events2[0].delta == '{"query": "' + + # Third update: ToolCallArgsEvent + assert len(events3) == 1 + assert events3[0].type == "TOOL_CALL_ARGS" + assert events3[0].delta == 'AI"}' + + # All should have same tool_call_id + assert events1[0].tool_call_id == events2[0].tool_call_id == events3[0].tool_call_id + + +async def test_tool_result_with_dict(): + """Test FunctionResultContent with dict result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + result_data = {"status": "success", "count": 42} + update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=result_data)]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallEndEvent + ToolCallResultEvent + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_123" + + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].tool_call_id == "call_123" + assert events[1].role == "tool" + # Result should be JSON-serialized + assert json.loads(events[1].content) == result_data + + +async def test_tool_result_with_string(): + """Test FunctionResultContent with string result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result="Search complete")]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].content == "Search complete" + + +async def test_tool_result_with_none(): + """Test FunctionResultContent with None result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=None)]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].content == "" + + +async def test_multiple_tool_results_in_sequence(): + """Test multiple tool results processed sequentially.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate( + contents=[ + FunctionResultContent(call_id="call_1", result="Result 1"), + FunctionResultContent(call_id="call_2", result="Result 2"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Each result emits: ToolCallEndEvent + ToolCallResultEvent = 4 events total + assert len(events) == 4 + assert events[0].tool_call_id == "call_1" + assert events[1].tool_call_id == "call_1" + assert events[2].tool_call_id == "call_2" + assert events[3].tool_call_id == "call_2" + + +async def test_function_approval_request_basic(): + """Test FunctionApprovalRequestContent conversion.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + func_call = FunctionCallContent( + call_id="call_123", + name="send_email", + arguments={"to": "user@example.com", "subject": "Test"}, + ) + approval = FunctionApprovalRequestContent( + id="approval_001", + function_call=func_call, + ) + + update = AgentRunResponseUpdate(contents=[approval]) + events = await bridge.from_agent_run_update(update) + + # Should emit: ToolCallEndEvent + CustomEvent + assert len(events) == 2 + + # First: ToolCallEndEvent to close the tool call + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_123" + + # Second: CustomEvent with approval details + assert events[1].type == "CUSTOM" + assert events[1].name == "function_approval_request" + assert events[1].value["id"] == "approval_001" + assert events[1].value["function_call"]["name"] == "send_email" + + +async def test_empty_predict_state_config(): + """Test behavior with no predictive state configuration.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={}, # Empty config + ) + + # Tool call with arguments + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1", arguments='{"content": "test"}'), + FunctionResultContent(call_id="call_1", result="Done"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should NOT emit StateDeltaEvent or confirm_changes + event_types = [e.type for e in events] + assert "STATE_DELTA" not in event_types + assert "STATE_SNAPSHOT" not in event_types + + # Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult, MessagesSnapshot + # MessagesSnapshotEvent is emitted after tool results to track the conversation + assert event_types == [ + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "MESSAGES_SNAPSHOT", + ] + + +async def test_tool_not_in_predict_state_config(): + """Test tool that doesn't match any predict_state_config entry.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_document", "tool_argument": "content"}, + }, + ) + + # Different tool name + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="search_web", call_id="call_1", arguments='{"query": "AI"}'), + FunctionResultContent(call_id="call_1", result="Results"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should NOT emit StateDeltaEvent or confirm_changes + event_types = [e.type for e in events] + assert "STATE_DELTA" not in event_types + assert "STATE_SNAPSHOT" not in event_types + + +async def test_state_management_tracking(): + """Test current_state and pending_state_updates tracking.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + initial_state = {"document": ""} + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_doc", "tool_argument": "content"}, + }, + current_state=initial_state, + ) + + # Streaming tool call + update1 = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Hello"}'), + ] + ) + await bridge.from_agent_run_update(update1) + + # Check pending_state_updates was populated + assert "document" in bridge.pending_state_updates + assert bridge.pending_state_updates["document"] == "Hello" + + # Tool result should update current_state + update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]) + await bridge.from_agent_run_update(update2) + + # current_state should be updated + assert bridge.current_state["document"] == "Hello" + + # pending_state_updates should be cleared + assert len(bridge.pending_state_updates) == 0 + + +async def test_wildcard_tool_argument(): + """Test tool_argument='*' uses all arguments as state value.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "recipe": {"tool": "create_recipe", "tool_argument": "*"}, + }, + current_state={}, + ) + + # Complete tool call with dict arguments + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent( + name="create_recipe", + call_id="call_1", + arguments={"title": "Pasta", "ingredients": ["pasta", "sauce"]}, + ), + FunctionResultContent(call_id="call_1", result="Created"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Find StateDeltaEvent + delta_events = [e for e in events if e.type == "STATE_DELTA"] + assert len(delta_events) > 0 + + # Value should be the entire arguments dict + delta = delta_events[0].delta[0] + assert delta["path"] == "/recipe" + assert delta["value"] == {"title": "Pasta", "ingredients": ["pasta", "sauce"]} + + +async def test_run_lifecycle_events(): + """Test RunStartedEvent and RunFinishedEvent creation.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + started = bridge.create_run_started_event() + assert started.type == "RUN_STARTED" + assert started.run_id == "test_run" + assert started.thread_id == "test_thread" + + finished = bridge.create_run_finished_event(result={"status": "complete"}) + assert finished.type == "RUN_FINISHED" + assert finished.run_id == "test_run" + assert finished.thread_id == "test_thread" + assert finished.result == {"status": "complete"} + + +async def test_message_lifecycle_events(): + """Test TextMessageStartEvent and TextMessageEndEvent creation.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + start = bridge.create_message_start_event("msg_123", role="assistant") + assert start.type == "TEXT_MESSAGE_START" + assert start.message_id == "msg_123" + assert start.role == "assistant" + + end = bridge.create_message_end_event("msg_123") + assert end.type == "TEXT_MESSAGE_END" + assert end.message_id == "msg_123" + + +async def test_state_event_creation(): + """Test StateSnapshotEvent and StateDeltaEvent creation helpers.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # StateSnapshotEvent + snapshot = bridge.create_state_snapshot_event({"document": "content"}) + assert snapshot.type == "STATE_SNAPSHOT" + assert snapshot.snapshot == {"document": "content"} + + # StateDeltaEvent with JSON Patch + delta = bridge.create_state_delta_event([{"op": "replace", "path": "/document", "value": "new content"}]) + assert delta.type == "STATE_DELTA" + assert len(delta.delta) == 1 + assert delta.delta[0]["op"] == "replace" + assert delta.delta[0]["path"] == "/document" + assert delta.delta[0]["value"] == "new content" + + +async def test_state_snapshot_after_tool_result(): + """Test StateSnapshotEvent emission after tool result with pending updates.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_doc", "tool_argument": "content"}, + }, + current_state={"document": ""}, + ) + + # Tool call with streaming args + update1 = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'), + ] + ) + await bridge.from_agent_run_update(update1) + + # Tool result should trigger StateSnapshotEvent + update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]) + events = await bridge.from_agent_run_update(update2) + + # Should have: ToolCallEnd, ToolCallResult, StateSnapshot, ToolCallStart (confirm_changes), ToolCallArgs, ToolCallEnd + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) == 1 + assert snapshot_events[0].snapshot["document"] == "Test" + + +async def test_message_id_persistence_across_chunks(): + """Test that message_id persists across multiple text chunks.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # First chunk + update1 = AgentRunResponseUpdate(contents=[TextContent(text="Hello ")]) + events1 = await bridge.from_agent_run_update(update1) + message_id = events1[0].message_id + + # Second chunk + update2 = AgentRunResponseUpdate(contents=[TextContent(text="world")]) + events2 = await bridge.from_agent_run_update(update2) + + # Should use same message_id + assert events2[0].message_id == message_id + assert bridge.current_message_id == message_id + + +async def test_tool_call_id_tracking(): + """Test tool_call_id tracking across streaming chunks.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # First chunk with name + update1 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search", call_id="call_1")]) + await bridge.from_agent_run_update(update1) + + assert bridge.current_tool_call_id == "call_1" + assert bridge.current_tool_call_name == "search" + + # Second chunk with args but no name + update2 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_1", arguments='{"q":"AI"}')]) + events2 = await bridge.from_agent_run_update(update2) + + # Should still track same tool call + assert bridge.current_tool_call_id == "call_1" + assert events2[0].tool_call_id == "call_1" + + +async def test_tool_name_reset_after_result(): + """Test current_tool_call_name is reset after tool result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_doc", "tool_argument": "content"}, + }, + ) + + # Tool call + update1 = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'), + ] + ) + await bridge.from_agent_run_update(update1) + + assert bridge.current_tool_call_name == "write_doc" + + # Tool result with predictive state (should trigger confirm_changes and reset) + update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]) + await bridge.from_agent_run_update(update2) + + # Tool name should be reset + assert bridge.current_tool_call_name is None + + +async def test_function_approval_with_wildcard_argument(): + """Test function approval with wildcard * argument.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "payload": {"tool": "submit", "tool_argument": "*"}, + }, + ) + + approval_content = FunctionApprovalRequestContent( + id="approval_1", + function_call=FunctionCallContent( + name="submit", call_id="call_1", arguments='{"key1": "value1", "key2": "value2"}' + ), + ) + + update = AgentRunResponseUpdate(contents=[approval_content]) + events = await bridge.from_agent_run_update(update) + + # Should emit StateSnapshotEvent with entire parsed args as value + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) == 1 + assert snapshot_events[0].snapshot["payload"] == {"key1": "value1", "key2": "value2"} + + +async def test_function_approval_missing_argument(): + """Test function approval when specified argument is not in parsed args.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "data": {"tool": "process", "tool_argument": "missing_field"}, + }, + ) + + approval_content = FunctionApprovalRequestContent( + id="approval_1", + function_call=FunctionCallContent(name="process", call_id="call_1", arguments='{"other_field": "value"}'), + ) + + update = AgentRunResponseUpdate(contents=[approval_content]) + events = await bridge.from_agent_run_update(update) + + # Should not emit StateSnapshotEvent since argument not found + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) == 0 + + +async def test_empty_predict_state_config_no_deltas(): + """Test with empty predict_state_config (no predictive updates).""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", predict_state_config={}) + + # Tool call with arguments + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="search", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should not emit any StateDeltaEvents + delta_events = [e for e in events if e.type == "STATE_DELTA"] + assert len(delta_events) == 0 + + +async def test_tool_with_no_matching_config(): + """Test tool call for tool not in predict_state_config.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}, + ) + + # Tool call for different tool + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="search_web", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should not emit StateDeltaEvents + delta_events = [e for e in events if e.type == "STATE_DELTA"] + assert len(delta_events) == 0 + + +async def test_tool_call_without_name_or_id(): + """Test handling FunctionCallContent with no name and no call_id.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # This should not crash but log an error + update = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="", arguments='{"arg": "val"}')]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallArgsEvent with generated ID + assert len(events) >= 1 + + +async def test_state_delta_count_logging(): + """Test that state delta count increments and logs at intervals.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}}, + ) + + # Emit multiple state deltas with different content each time + for i in range(15): + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'), + ] + ) + # Set the tool name to match config + bridge.current_tool_call_name = "write" + await bridge.from_agent_run_update(update) + + # State delta count should have incremented (one per unique state update) + assert bridge.state_delta_count >= 1 diff --git a/python/packages/ag-ui/tests/test_human_in_the_loop.py b/python/packages/ag-ui/tests/test_human_in_the_loop.py new file mode 100644 index 0000000000..92f6d69926 --- /dev/null +++ b/python/packages/ag-ui/tests/test_human_in_the_loop.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for human in the loop (function approval requests).""" + +from agent_framework import FunctionApprovalRequestContent, FunctionCallContent +from agent_framework._types import AgentRunResponseUpdate + +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +async def test_function_approval_request_emission(): + """Test that CustomEvent is emitted for FunctionApprovalRequestContent.""" + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + ) + + # Create approval request + func_call = FunctionCallContent( + call_id="call_123", + name="send_email", + arguments={"to": "user@example.com", "subject": "Test"}, + ) + approval_request = FunctionApprovalRequestContent( + id="approval_001", + function_call=func_call, + ) + + update = AgentRunResponseUpdate(contents=[approval_request]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallEndEvent + CustomEvent for approval request + assert len(events) == 2 + + # First event: ToolCallEndEvent to close the tool call + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_123" + + # Second event: CustomEvent with approval details + event = events[1] + assert event.type == "CUSTOM" + assert event.name == "function_approval_request" + assert event.value["id"] == "approval_001" + assert event.value["function_call"]["call_id"] == "call_123" + assert event.value["function_call"]["name"] == "send_email" + assert event.value["function_call"]["arguments"]["to"] == "user@example.com" + assert event.value["function_call"]["arguments"]["subject"] == "Test" + + +async def test_multiple_approval_requests(): + """Test handling multiple approval requests in one update.""" + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + ) + + func_call_1 = FunctionCallContent( + call_id="call_1", + name="create_event", + arguments={"title": "Meeting"}, + ) + approval_1 = FunctionApprovalRequestContent( + id="approval_1", + function_call=func_call_1, + ) + + func_call_2 = FunctionCallContent( + call_id="call_2", + name="book_room", + arguments={"room": "Conference A"}, + ) + approval_2 = FunctionApprovalRequestContent( + id="approval_2", + function_call=func_call_2, + ) + + update = AgentRunResponseUpdate(contents=[approval_1, approval_2]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallEndEvent + CustomEvent for each approval (4 events total) + assert len(events) == 4 + + # Events should alternate: End, Custom, End, Custom + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_1" + + assert events[1].type == "CUSTOM" + assert events[1].name == "function_approval_request" + assert events[1].value["id"] == "approval_1" + + assert events[2].type == "TOOL_CALL_END" + assert events[2].tool_call_id == "call_2" + + assert events[3].type == "CUSTOM" + assert events[3].name == "function_approval_request" + assert events[3].value["id"] == "approval_2" diff --git a/python/packages/ag-ui/tests/test_message_adapters.py b/python/packages/ag-ui/tests/test_message_adapters.py new file mode 100644 index 0000000000..1a5bb0ccd7 --- /dev/null +++ b/python/packages/ag-ui/tests/test_message_adapters.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for message adapters.""" + +import pytest +from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent + +from agent_framework_ag_ui._message_adapters import ( + agent_framework_messages_to_agui, + agui_messages_to_agent_framework, + extract_text_from_contents, +) + + +@pytest.fixture +def sample_agui_message(): + """Create a sample AG-UI message.""" + return {"role": "user", "content": "Hello", "id": "msg-123"} + + +@pytest.fixture +def sample_agent_framework_message(): + """Create a sample Agent Framework message.""" + return ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")], message_id="msg-123") + + +def test_agui_to_agent_framework_basic(sample_agui_message): + """Test converting AG-UI message to Agent Framework.""" + messages = agui_messages_to_agent_framework([sample_agui_message]) + + assert len(messages) == 1 + assert messages[0].role == Role.USER + assert messages[0].message_id == "msg-123" + + +def test_agent_framework_to_agui_basic(sample_agent_framework_message): + """Test converting Agent Framework message to AG-UI.""" + messages = agent_framework_messages_to_agui([sample_agent_framework_message]) + + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert messages[0]["content"] == "Hello" + assert messages[0]["id"] == "msg-123" + + +def test_agui_tool_result_to_agent_framework(): + """Test converting AG-UI tool result message to Agent Framework.""" + tool_result_message = { + "role": "tool", + "content": '{"accepted": true, "steps": []}', + "toolCallId": "call_123", + "id": "msg_456", + } + + messages = agui_messages_to_agent_framework([tool_result_message]) + + assert len(messages) == 1 + message = messages[0] + + assert message.role == Role.USER + + assert len(message.contents) == 1 + assert isinstance(message.contents[0], TextContent) + assert message.contents[0].text == '{"accepted": true, "steps": []}' + + assert hasattr(message, "metadata") + assert message.metadata is not None + assert message.metadata.get("is_tool_result") is True + assert message.metadata.get("tool_call_id") == "call_123" + + +def test_agui_multiple_messages_to_agent_framework(): + """Test converting multiple AG-UI messages.""" + messages_input = [ + {"role": "user", "content": "First message", "id": "msg-1"}, + {"role": "assistant", "content": "Second message", "id": "msg-2"}, + {"role": "user", "content": "Third message", "id": "msg-3"}, + ] + + messages = agui_messages_to_agent_framework(messages_input) + + assert len(messages) == 3 + assert messages[0].role == Role.USER + assert messages[1].role == Role.ASSISTANT + assert messages[2].role == Role.USER + + +def test_agui_empty_messages(): + """Test handling of empty messages list.""" + messages = agui_messages_to_agent_framework([]) + assert len(messages) == 0 + + +def test_agui_function_approvals(): + """Test converting function approvals from AG-UI to Agent Framework.""" + agui_msg = { + "role": "user", + "function_approvals": [ + { + "call_id": "call-1", + "name": "search", + "arguments": {"query": "test"}, + "approved": True, + "id": "approval-1", + }, + { + "call_id": "call-2", + "name": "update", + "arguments": {"value": 42}, + "approved": False, + "id": "approval-2", + }, + ], + "id": "msg-123", + } + + messages = agui_messages_to_agent_framework([agui_msg]) + + assert len(messages) == 1 + msg = messages[0] + assert msg.role == Role.USER + assert len(msg.contents) == 2 + + from agent_framework import FunctionApprovalResponseContent + + assert isinstance(msg.contents[0], FunctionApprovalResponseContent) + assert msg.contents[0].approved is True + assert msg.contents[0].id == "approval-1" + assert msg.contents[0].function_call.name == "search" + assert msg.contents[0].function_call.call_id == "call-1" + + assert isinstance(msg.contents[1], FunctionApprovalResponseContent) + assert msg.contents[1].approved is False + + +def test_agui_system_role(): + """Test converting system role messages.""" + messages = agui_messages_to_agent_framework([{"role": "system", "content": "System prompt"}]) + + assert len(messages) == 1 + assert messages[0].role == Role.SYSTEM + + +def test_agui_non_string_content(): + """Test handling non-string content.""" + messages = agui_messages_to_agent_framework([{"role": "user", "content": {"nested": "object"}}]) + + assert len(messages) == 1 + assert len(messages[0].contents) == 1 + assert isinstance(messages[0].contents[0], TextContent) + assert "nested" in messages[0].contents[0].text + + +def test_agui_message_without_id(): + """Test message without ID field.""" + messages = agui_messages_to_agent_framework([{"role": "user", "content": "No ID"}]) + + assert len(messages) == 1 + assert messages[0].message_id is None + + +def test_agent_framework_to_agui_with_tool_calls(): + """Test converting Agent Framework message with tool calls to AG-UI.""" + msg = ChatMessage( + role=Role.ASSISTANT, + contents=[ + TextContent(text="Calling tool"), + FunctionCallContent(call_id="call-123", name="search", arguments={"query": "test"}), + ], + message_id="msg-456", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + assert agui_msg["role"] == "assistant" + assert agui_msg["content"] == "Calling tool" + assert "tool_calls" in agui_msg + assert len(agui_msg["tool_calls"]) == 1 + assert agui_msg["tool_calls"][0]["id"] == "call-123" + assert agui_msg["tool_calls"][0]["type"] == "function" + assert agui_msg["tool_calls"][0]["function"]["name"] == "search" + assert agui_msg["tool_calls"][0]["function"]["arguments"] == {"query": "test"} + + +def test_agent_framework_to_agui_multiple_text_contents(): + """Test concatenating multiple text contents.""" + msg = ChatMessage( + role=Role.ASSISTANT, + contents=[TextContent(text="Part 1 "), TextContent(text="Part 2")], + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + assert messages[0]["content"] == "Part 1 Part 2" + + +def test_agent_framework_to_agui_no_message_id(): + """Test message without message_id.""" + msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + assert "id" not in messages[0] + + +def test_agent_framework_to_agui_system_role(): + """Test system role conversion.""" + msg = ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="System")]) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + assert messages[0]["role"] == "system" + + +def test_extract_text_from_contents(): + """Test extracting text from contents list.""" + contents = [TextContent(text="Hello "), TextContent(text="World")] + + result = extract_text_from_contents(contents) + + assert result == "Hello World" + + +def test_extract_text_from_empty_contents(): + """Test extracting text from empty contents.""" + result = extract_text_from_contents([]) + + assert result == "" + + +class CustomTextContent: + """Custom content with text attribute.""" + + def __init__(self, text: str): + self.text = text + + +def test_extract_text_from_custom_contents(): + """Test extracting text from custom content objects.""" + contents = [CustomTextContent(text="Custom "), TextContent(text="Mixed")] + + result = extract_text_from_contents(contents) + + assert result == "Custom Mixed" diff --git a/python/packages/ag-ui/tests/test_shared_state.py b/python/packages/ag-ui/tests/test_shared_state.py new file mode 100644 index 0000000000..578d48ecd0 --- /dev/null +++ b/python/packages/ag-ui/tests/test_shared_state.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for shared state management.""" + +import pytest +from ag_ui.core import StateSnapshotEvent +from agent_framework import ChatAgent, TextContent +from agent_framework._types import ChatResponseUpdate + +from agent_framework_ag_ui._agent import AgentFrameworkAgent +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +@pytest.fixture +def mock_agent(): + """Create a mock agent for testing.""" + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello!")]) + + return ChatAgent( + name="test_agent", + instructions="Test agent", + chat_client=MockChatClient(), + ) + + +def test_state_snapshot_event(): + """Test creating state snapshot events.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + state = { + "recipe": { + "name": "Chocolate Chip Cookies", + "ingredients": ["flour", "sugar", "chocolate chips"], + "instructions": ["Mix ingredients", "Bake at 350°F"], + "servings": 24, + } + } + + event = bridge.create_state_snapshot_event(state) + + assert isinstance(event, StateSnapshotEvent) + assert event.snapshot == state + assert event.snapshot["recipe"]["name"] == "Chocolate Chip Cookies" + assert len(event.snapshot["recipe"]["ingredients"]) == 3 + + +def test_state_delta_event(): + """Test creating state delta events using JSON Patch format.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # JSON Patch operations (RFC 6902) + delta = [ + {"op": "add", "path": "/recipe/ingredients/-", "value": "vanilla extract"}, + {"op": "replace", "path": "/recipe/servings", "value": 30}, + ] + + event = bridge.create_state_delta_event(delta) + + assert event.delta == delta + assert len(event.delta) == 2 + assert event.delta[0]["op"] == "add" + assert event.delta[1]["op"] == "replace" + + +async def test_agent_with_initial_state(mock_agent): + """Test agent emits state snapshot when initial state provided.""" + state_schema = {"recipe": {"type": "object", "properties": {"name": {"type": "string"}}}} + + agent = AgentFrameworkAgent( + agent=mock_agent, + state_schema=state_schema, + ) + + initial_state = {"recipe": {"name": "Test Recipe"}} + + input_data = { + "messages": [{"role": "user", "content": "Hello"}], + "state": initial_state, + } + + events = [] + async for event in agent.run_agent(input_data): + events.append(event) + + # Should have RunStartedEvent, StateSnapshotEvent, RunFinishedEvent at minimum + snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)] + assert len(snapshot_events) == 1 + assert snapshot_events[0].snapshot == initial_state + + +async def test_agent_without_state_schema(mock_agent): + """Test agent doesn't emit state events without state schema.""" + agent = AgentFrameworkAgent(agent=mock_agent) + + input_data = { + "messages": [{"role": "user", "content": "Hello"}], + "state": {"some": "state"}, + } + + events = [] + async for event in agent.run_agent(input_data): + events.append(event) + + # Should NOT have any StateSnapshotEvent + snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)] + assert len(snapshot_events) == 0 diff --git a/python/packages/ag-ui/tests/test_structured_output.py b/python/packages/ag-ui/tests/test_structured_output.py new file mode 100644 index 0000000000..878002a8e1 --- /dev/null +++ b/python/packages/ag-ui/tests/test_structured_output.py @@ -0,0 +1,257 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for structured output handling in _agent.py.""" + +import json +from typing import Any + +from agent_framework import ChatAgent, ChatOptions, TextContent +from agent_framework._types import ChatResponseUpdate +from pydantic import BaseModel + + +class RecipeOutput(BaseModel): + """Test Pydantic model for recipe output.""" + + recipe: dict[str, Any] + message: str | None = None + + +class StepsOutput(BaseModel): + """Test Pydantic model for steps output.""" + + steps: list[dict[str, Any]] + message: str | None = None + + +class GenericOutput(BaseModel): + """Test Pydantic model for generic data.""" + + data: dict[str, Any] + + +async def test_structured_output_with_recipe(): + """Test structured output processing with recipe state.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Simulate structured output + yield ChatResponseUpdate( + contents=[TextContent(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')] + ) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=RecipeOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"recipe": {"type": "object"}}, + ) + + input_data = {"messages": [{"role": "user", "content": "Make pasta"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent with recipe + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + # Find snapshot with recipe + recipe_snapshots = [e for e in snapshot_events if "recipe" in e.snapshot] + assert len(recipe_snapshots) >= 1 + assert recipe_snapshots[0].snapshot["recipe"] == {"name": "Pasta"} + + # Should also emit message as text + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert any("Here is your recipe" in e.delta for e in text_events) + + +async def test_structured_output_with_steps(): + """Test structured output processing with steps state.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + steps_data = { + "steps": [ + {"id": "1", "description": "Step 1", "status": "pending"}, + {"id": "2", "description": "Step 2", "status": "pending"}, + ] + } + yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(steps_data))]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=StepsOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"steps": {"type": "array"}}, + ) + + input_data = {"messages": [{"role": "user", "content": "Do steps"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent with steps + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # Snapshot should contain steps + steps_snapshots = [e for e in snapshot_events if "steps" in e.snapshot] + assert len(steps_snapshots) >= 1 + assert len(steps_snapshots[0].snapshot["steps"]) == 2 + assert steps_snapshots[0].snapshot["steps"][0]["id"] == "1" + + +async def test_structured_output_with_no_schema_match(): + """Test structured output when response fields don't match state_schema keys.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Response has "data" field but schema expects "result" field + yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}}')]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=GenericOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"result": {"type": "object"}}, # Schema expects "result", not "data" + ) + + input_data = {"messages": [{"role": "user", "content": "Generate data"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent but with no state updates since no schema fields match + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + # Initial state snapshot from state_schema initialization + assert len(snapshot_events) >= 1 + + +async def test_structured_output_without_schema(): + """Test structured output without state_schema treats all fields as state.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class DataOutput(BaseModel): + """Output with data and info fields.""" + + data: dict[str, Any] + info: str + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}, "info": "processed"}')]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=DataOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + # No state_schema - all non-message fields treated as state + ) + + input_data = {"messages": [{"role": "user", "content": "Generate data"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent with both data and info fields + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + assert "data" in snapshot_events[0].snapshot + assert "info" in snapshot_events[0].snapshot + assert snapshot_events[0].snapshot["data"] == {"key": "value"} + assert snapshot_events[0].snapshot["info"] == "processed" + + +async def test_no_structured_output_when_no_response_format(): + """Test that structured output path is skipped when no response_format.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Regular text")]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + # No response_format set + + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit text content normally + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_events) > 0 + assert text_events[0].delta == "Regular text" + + +async def test_structured_output_with_message_field(): + """Test structured output that includes a message field.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"} + yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(output_data))]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=RecipeOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"recipe": {"type": "object"}}, + ) + + input_data = {"messages": [{"role": "user", "content": "Make salad"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit the message as text + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert any("Fresh salad recipe ready" in e.delta for e in text_events) + + # Should also have TextMessageStart and TextMessageEnd + start_events = [e for e in events if e.type == "TEXT_MESSAGE_START"] + end_events = [e for e in events if e.type == "TEXT_MESSAGE_END"] + assert len(start_events) >= 1 + assert len(end_events) >= 1 + + +async def test_empty_updates_no_structured_processing(): + """Test that empty updates don't trigger structured output processing.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Return nothing + if False: + yield + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=RecipeOutput) + + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Test"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should only have start and end events + assert len(events) == 2 # RunStarted, RunFinished diff --git a/python/packages/ag-ui/tests/test_types.py b/python/packages/ag-ui/tests/test_types.py new file mode 100644 index 0000000000..3c61278d9e --- /dev/null +++ b/python/packages/ag-ui/tests/test_types.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for type definitions in _types.py.""" + +from agent_framework_ag_ui._types import AgentState, PredictStateConfig, RunMetadata + + +class TestPredictStateConfig: + """Test PredictStateConfig TypedDict.""" + + def test_predict_state_config_creation(self) -> None: + """Test creating a PredictStateConfig dict.""" + config: PredictStateConfig = { + "state_key": "document", + "tool": "write_document", + "tool_argument": "content", + } + + assert config["state_key"] == "document" + assert config["tool"] == "write_document" + assert config["tool_argument"] == "content" + + def test_predict_state_config_with_none_tool_argument(self) -> None: + """Test PredictStateConfig with None tool_argument.""" + config: PredictStateConfig = { + "state_key": "status", + "tool": "update_status", + "tool_argument": None, + } + + assert config["state_key"] == "status" + assert config["tool"] == "update_status" + assert config["tool_argument"] is None + + def test_predict_state_config_type_validation(self) -> None: + """Test that PredictStateConfig validates field types at runtime.""" + config: PredictStateConfig = { + "state_key": "test", + "tool": "test_tool", + "tool_argument": "arg", + } + + assert isinstance(config["state_key"], str) + assert isinstance(config["tool"], str) + assert isinstance(config["tool_argument"], (str, type(None))) + + +class TestRunMetadata: + """Test RunMetadata TypedDict.""" + + def test_run_metadata_creation(self) -> None: + """Test creating a RunMetadata dict.""" + metadata: RunMetadata = { + "run_id": "run-123", + "thread_id": "thread-456", + "predict_state": [ + { + "state_key": "document", + "tool": "write_document", + "tool_argument": "content", + } + ], + } + + assert metadata["run_id"] == "run-123" + assert metadata["thread_id"] == "thread-456" + assert metadata["predict_state"] is not None + assert len(metadata["predict_state"]) == 1 + assert metadata["predict_state"][0]["state_key"] == "document" + + def test_run_metadata_with_none_predict_state(self) -> None: + """Test RunMetadata with None predict_state.""" + metadata: RunMetadata = { + "run_id": "run-789", + "thread_id": "thread-012", + "predict_state": None, + } + + assert metadata["run_id"] == "run-789" + assert metadata["thread_id"] == "thread-012" + assert metadata["predict_state"] is None + + def test_run_metadata_empty_predict_state(self) -> None: + """Test RunMetadata with empty predict_state list.""" + metadata: RunMetadata = { + "run_id": "run-345", + "thread_id": "thread-678", + "predict_state": [], + } + + assert metadata["run_id"] == "run-345" + assert metadata["thread_id"] == "thread-678" + assert metadata["predict_state"] == [] + + +class TestAgentState: + """Test AgentState TypedDict.""" + + def test_agent_state_creation(self) -> None: + """Test creating an AgentState dict.""" + state: AgentState = { + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + ] + } + + assert state["messages"] is not None + assert len(state["messages"]) == 2 + assert state["messages"][0]["role"] == "user" + assert state["messages"][1]["role"] == "assistant" + + def test_agent_state_with_none_messages(self) -> None: + """Test AgentState with None messages.""" + state: AgentState = {"messages": None} + + assert state["messages"] is None + + def test_agent_state_empty_messages(self) -> None: + """Test AgentState with empty messages list.""" + state: AgentState = {"messages": []} + + assert state["messages"] == [] + + def test_agent_state_complex_messages(self) -> None: + """Test AgentState with complex message structures.""" + state: AgentState = { + "messages": [ + { + "role": "user", + "content": "Test", + "metadata": {"timestamp": "2025-10-30"}, + }, + { + "role": "assistant", + "content": "Response", + "tool_calls": [{"name": "search", "args": {}}], + }, + ] + } + + assert state["messages"] is not None + assert len(state["messages"]) == 2 + assert "metadata" in state["messages"][0] + assert "tool_calls" in state["messages"][1] diff --git a/python/packages/ag-ui/tests/test_utils.py b/python/packages/ag-ui/tests/test_utils.py new file mode 100644 index 0000000000..9bc477310c --- /dev/null +++ b/python/packages/ag-ui/tests/test_utils.py @@ -0,0 +1,199 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for utilities.""" + +from dataclasses import dataclass +from datetime import date, datetime + +from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state + + +def test_generate_event_id(): + """Test event ID generation.""" + id1 = generate_event_id() + id2 = generate_event_id() + + assert id1 != id2 + assert isinstance(id1, str) + assert len(id1) > 0 + + +def test_merge_state(): + """Test state merging.""" + current = {"a": 1, "b": 2} + update = {"b": 3, "c": 4} + + result = merge_state(current, update) + + assert result["a"] == 1 + assert result["b"] == 3 + assert result["c"] == 4 + + +def test_merge_state_empty_update(): + """Test merging with empty update.""" + current = {"x": 10, "y": 20} + update = {} + + result = merge_state(current, update) + + assert result == current + assert result is not current + + +def test_merge_state_empty_current(): + """Test merging with empty current state.""" + current = {} + update = {"a": 1, "b": 2} + + result = merge_state(current, update) + + assert result == update + + +def test_merge_state_deep_copy(): + """Test that merge_state creates a deep copy preventing mutation of original.""" + current = {"recipe": {"name": "Cake", "ingredients": ["flour", "sugar"]}} + update = {"other": "value"} + + result = merge_state(current, update) + + result["recipe"]["ingredients"].append("eggs") + + assert "eggs" not in current["recipe"]["ingredients"] + assert current["recipe"]["ingredients"] == ["flour", "sugar"] + assert result["recipe"]["ingredients"] == ["flour", "sugar", "eggs"] + + +def test_make_json_safe_basic(): + """Test JSON serialization of basic types.""" + assert make_json_safe("text") == "text" + assert make_json_safe(123) == 123 + assert make_json_safe(None) is None + assert make_json_safe(3.14) == 3.14 + assert make_json_safe(True) is True + assert make_json_safe(False) is False + + +def test_make_json_safe_datetime(): + """Test datetime serialization.""" + dt = datetime(2025, 10, 30, 12, 30, 45) + result = make_json_safe(dt) + assert result == "2025-10-30T12:30:45" + + +def test_make_json_safe_date(): + """Test date serialization.""" + d = date(2025, 10, 30) + result = make_json_safe(d) + assert result == "2025-10-30" + + +@dataclass +class SampleDataclass: + """Sample dataclass for testing.""" + + name: str + value: int + + +def test_make_json_safe_dataclass(): + """Test dataclass serialization.""" + obj = SampleDataclass(name="test", value=42) + result = make_json_safe(obj) + assert result == {"name": "test", "value": 42} + + +class ModelDumpObject: + """Object with model_dump method.""" + + def model_dump(self): + return {"type": "model", "data": "dump"} + + +def test_make_json_safe_model_dump(): + """Test object with model_dump method.""" + obj = ModelDumpObject() + result = make_json_safe(obj) + assert result == {"type": "model", "data": "dump"} + + +class DictObject: + """Object with dict method.""" + + def dict(self): + return {"type": "dict", "method": "call"} + + +def test_make_json_safe_dict_method(): + """Test object with dict method.""" + obj = DictObject() + result = make_json_safe(obj) + assert result == {"type": "dict", "method": "call"} + + +class CustomObject: + """Custom object with __dict__.""" + + def __init__(self): + self.field1 = "value1" + self.field2 = 123 + + +def test_make_json_safe_dict_attribute(): + """Test object with __dict__ attribute.""" + obj = CustomObject() + result = make_json_safe(obj) + assert result == {"field1": "value1", "field2": 123} + + +def test_make_json_safe_list(): + """Test list serialization.""" + lst = [1, "text", None, {"key": "value"}] + result = make_json_safe(lst) + assert result == [1, "text", None, {"key": "value"}] + + +def test_make_json_safe_tuple(): + """Test tuple serialization.""" + tpl = (1, 2, 3) + result = make_json_safe(tpl) + assert result == [1, 2, 3] + + +def test_make_json_safe_dict(): + """Test dict serialization.""" + d = {"a": 1, "b": {"c": 2}} + result = make_json_safe(d) + assert result == {"a": 1, "b": {"c": 2}} + + +def test_make_json_safe_nested(): + """Test nested structure serialization.""" + obj = { + "datetime": datetime(2025, 10, 30), + "list": [1, 2, CustomObject()], + "nested": {"value": SampleDataclass(name="nested", value=99)}, + } + result = make_json_safe(obj) + + assert result["datetime"] == "2025-10-30T00:00:00" + assert result["list"][0] == 1 + assert result["list"][2] == {"field1": "value1", "field2": 123} + assert result["nested"]["value"] == {"name": "nested", "value": 99} + + +class UnserializableObject: + """Object that can't be serialized by standard methods.""" + + def __init__(self): + # Add attribute to trigger __dict__ fallback path + pass + + +def test_make_json_safe_fallback(): + """Test fallback to dict for objects with __dict__.""" + obj = UnserializableObject() + result = make_json_safe(obj) + # Objects with __dict__ return their __dict__ dict + assert isinstance(result, dict) diff --git a/python/pyproject.toml b/python/pyproject.toml index 8db0916229..72eb4ba258 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ dependencies = [ "agent-framework-core", "agent-framework-a2a", + "agent-framework-ag-ui", "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-chatkit", @@ -89,6 +90,7 @@ members = [ "packages/*" ] agent-framework = { workspace = true } agent-framework-core = { workspace = true } agent-framework-a2a = { workspace = true } +agent-framework-ag-ui = { workspace = true } agent-framework-azure-ai = { workspace = true } agent-framework-chatkit = { workspace = true } agent-framework-copilotstudio = { workspace = true } @@ -241,6 +243,7 @@ cmd = """ pytest --import-mode=importlib --cov=agent_framework --cov=agent_framework_a2a +--cov=agent_framework_ag_ui --cov=agent_framework_azure_ai --cov=agent_framework_chatkit --cov=agent_framework_copilotstudio diff --git a/python/uv.lock b/python/uv.lock index 7e9e6798a7..19df6a548f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -31,6 +31,7 @@ supported-markers = [ members = [ "agent-framework", "agent-framework-a2a", + "agent-framework-ag-ui", "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-chatkit", @@ -72,12 +73,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, ] +[[package]] +name = "ag-ui-protocol" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/d7/a8f8789b3b8b5f7263a902361468e8dfefd85ec63d1d5398579b9175d76d/ag_ui_protocol-0.1.9.tar.gz", hash = "sha256:94d75e3919ff75e0b608a7eed445062ea0e6f11cd33b3386a7649047e0c7abd3", size = 4988, upload-time = "2025-09-19T13:36:26.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/50/2bb71a2a9135f4d88706293773320d185789b592987c09f79e9bf2f4875f/ag_ui_protocol-0.1.9-py3-none-any.whl", hash = "sha256:44c1238b0576a3915b3a16e1b3855724e08e92ebc96b1ff29379fbd3bfbd400b", size = 7070, upload-time = "2025-09-19T13:36:25.791Z" }, +] + [[package]] name = "agent-framework" version = "1.0.0b251104" source = { virtual = "." } dependencies = [ { 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", 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'" }, @@ -121,6 +135,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "agent-framework-a2a", editable = "packages/a2a" }, + { name = "agent-framework-ag-ui", editable = "packages/ag-ui" }, { name = "agent-framework-anthropic", editable = "packages/anthropic" }, { name = "agent-framework-azure-ai", editable = "packages/azure-ai" }, { name = "agent-framework-chatkit", editable = "packages/chatkit" }, @@ -176,6 +191,36 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, ] +[[package]] +name = "agent-framework-ag-ui" +version = "0.1.0" +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 = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx", 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'" }, +] + +[package.metadata] +requires-dist = [ + { name = "ag-ui-protocol", specifier = ">=0.1.9" }, + { name = "agent-framework-core", editable = "packages/core" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "uvicorn", specifier = ">=0.30.0" }, +] +provides-extras = ["dev"] + [[package]] name = "agent-framework-anthropic" version = "1.0.0b251104" @@ -679,6 +724,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -733,14 +787,14 @@ wheels = [ [[package]] name = "apscheduler" -version = "3.11.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, ] [[package]] @@ -1650,16 +1704,17 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.14" +version = "0.121.0" 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" }, ] [[package]] @@ -2264,11 +2319,11 @@ wheels = [ [[package]] name = "httpdbg" -version = "2.1.1" +version = "2.1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/07/bdc4b46bf9cda06b4bdb5b0dea2e1c5408fd91387823e1cb2cfebd79fde4/httpdbg-2.1.1.tar.gz", hash = "sha256:11b268e9224fdeccc7e5436b350154c287a1af65406047b5f6438461fc45486c", size = 81226, upload-time = "2025-10-26T18:42:41.896Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/c0/a54d8705ae57e76679cf21dbc6dba3eb4c5cb9f99fcd9cb99e159fb12a9d/httpdbg-2.1.3.tar.gz", hash = "sha256:da32fd7cab8032927ba4717c6c9108dd4aeb0d9a42636d34a43ab11541daac26", size = 80694, upload-time = "2025-11-02T13:48:13.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/8e/8b0e91e4c5426f503149f86df5b2a142afb11abada57cf09a5990a933407/httpdbg-2.1.1-py3-none-any.whl", hash = "sha256:ef7137752cb2c79b3084b50a9534e7d1ba587d9ad531ac0807a3563ceb7a74e0", size = 89045, upload-time = "2025-10-26T18:42:39.017Z" }, + { url = "https://files.pythonhosted.org/packages/33/6e/567ace955933023403e4861d161de8b559d712b559e445cc6d9a95d8e26c/httpdbg-2.1.3-py3-none-any.whl", hash = "sha256:9faa4d66f308670ddde0c6b05281066cb10b56846e6c4d3eb712123c28ea019d", size = 88173, upload-time = "2025-11-02T13:48:12.466Z" }, ] [[package]] @@ -2681,7 +2736,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.8.1" +version = "3.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2695,14 +2750,14 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/0b/81f9c6a982f79c112b7f10bfd6f3a4871e6fa3e4fe8d078b6112abfd3c08/langfuse-3.8.1.tar.gz", hash = "sha256:2464ae3f8386d80e1252a0e7406e3be4121e792a74f1b1c21d9950f658e5168d", size = 197401, upload-time = "2025-10-22T13:35:52.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/c6/1bdb6c68ebc2b7d3875861cf99715e227bcd909a758df8af329f81f6e7af/langfuse-3.9.0.tar.gz", hash = "sha256:ed02744ab184a320dba5662be09be21441a467cc84db7e9a67c8bb6baec9fb5c", size = 201850, upload-time = "2025-11-03T10:25:49.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/f9/538af0fc4219eb2484ba319483bce3383146f7a0923d5f39e464ad9a504b/langfuse-3.8.1-py3-none-any.whl", hash = "sha256:5b94b66ec0b0de388a8ea1f078b32c1666b5825b36eab863a21fdee78c53b3bb", size = 364580, upload-time = "2025-10-22T13:35:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/66/de/66ab298aecc0b50465824e7db5df77e43f872dcd8642d3c91d11be3ac6f7/langfuse-3.9.0-py3-none-any.whl", hash = "sha256:de46c47717822de46ad4a2563be5d775ca896dc4d0955a83b4d12e1ce5e249a9", size = 369620, upload-time = "2025-11-03T10:25:47.747Z" }, ] [[package]] name = "litellm" -version = "1.79.0" +version = "1.79.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2718,9 +2773,9 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/52/2853febf8ea3072d8c76e3ee22d3168e6a4f97ebd8f21905e815a381c58b/litellm-1.79.0.tar.gz", hash = "sha256:f58bb751222ee0e1ffecb2d44987999f9fa94130a6d1a478e19a3e5e8b9a7414", size = 11146414, upload-time = "2025-10-26T01:20:55.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/12/1c30f1019892399a488ed60ebcdfed3e2603123d9591030abc8c702ff37a/litellm-1.79.1.tar.gz", hash = "sha256:c1cf0232c01e7ad4b8442d2cdd78973ce74dfda37ad1d9f0ec3c911510e26523", size = 11216675, upload-time = "2025-11-01T19:22:05.523Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/26/a5fef380af5d6a2f47cda979d88561af1e1a8efc07da2ef72c0e8cb6842c/litellm-1.79.0-py3-none-any.whl", hash = "sha256:93414b6ed55fa9e3268e8cb3100faab960c9ecd18173129ccd85471cf3db4f1a", size = 10197864, upload-time = "2025-10-26T01:20:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/ac5905dfe9c0c195e59c36ea431277090dd2aa1acbcc514f781fa87a5903/litellm-1.79.1-py3-none-any.whl", hash = "sha256:738f7bf36b31514ac11cc71f65718238b57696fcf22f8b3f1e57c44daf17a569", size = 10285849, upload-time = "2025-11-01T19:22:01.637Z" }, ] [package.optional-dependencies] @@ -2761,11 +2816,11 @@ wheels = [ [[package]] name = "litellm-proxy-extras" -version = "0.2.29" +version = "0.2.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/23/8b262f0301e02f7a70f299e68d06752934f6dd95d0a6b82ce871e5de4d81/litellm_proxy_extras-0.2.29.tar.gz", hash = "sha256:236c1cf8d9b0128392bb843ff8553918b0a9c299f2b3bfdc9ecc6b4547ce195e", size = 16500, upload-time = "2025-10-23T21:19:10.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/007a87b17834c5a24e15798ae32dd156d77528b12f086f4176bb7e3f4401/litellm_proxy_extras-0.2.31.tar.gz", hash = "sha256:6d4c96dfe28fa439eaf4e8d19b73718530bc2c59cd1e4cf560388c6bce5476bb", size = 16648, upload-time = "2025-11-01T01:18:47.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/70/c8ec18235f4bbe3c8486b2909c3d5fc23cdbd08b2c7504ae8c02ed813c83/litellm_proxy_extras-0.2.29-py3-none-any.whl", hash = "sha256:27b7efc69829ed8745de7f469110c1f6a82e4f994bd8de3ac6b16dc2806a14b0", size = 33565, upload-time = "2025-10-23T21:19:09.821Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5f/6a0add2cac34a370da62d3bf7476035f5f10519740dfe78410256f8945b1/litellm_proxy_extras-0.2.31-py3-none-any.whl", hash = "sha256:7a66ae2810e451977fb1dfed6dac81971c6a4efbce7d57c896dce280b50ce359", size = 34130, upload-time = "2025-11-01T01:18:46.485Z" }, ] [[package]] @@ -2955,7 +3010,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.19.0" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2964,15 +3019,16 @@ dependencies = [ { 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 = "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 = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/22/fae38092e6c2995c03232635028510d77e7decff31b4ae79dfa0ba99c635/mcp-1.20.0.tar.gz", hash = "sha256:9ccc09eaadbfbcbbdab1c9723cfe2e0d1d9e324d7d3ce7e332ef90b09ed35177", size = 451377, upload-time = "2025-10-30T22:14:53.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" }, + { url = "https://files.pythonhosted.org/packages/df/00/76fc92f4892d47fecb37131d0e95ea69259f077d84c68f6793a0d96cfe80/mcp-1.20.0-py3-none-any.whl", hash = "sha256:d0dc06f93653f7432ff89f694721c87f79876b6f93741bf628ad1e48f7ac5e5d", size = 173136, upload-time = "2025-10-30T22:14:51.078Z" }, ] [package.optional-dependencies] @@ -3009,31 +3065,31 @@ wheels = [ [[package]] name = "microsoft-agents-activity" -version = "0.5.1" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/96/4416c5b3f13309d7503f3db3c2bfc321824366b68a240ed71e8145634c3d/microsoft_agents_activity-0.5.1.tar.gz", hash = "sha256:07be29aca58ea9d8279155cfa4c00261e3a18bdf718c8164c1d87e3e57ad527b", size = 55830, upload-time = "2025-10-28T19:27:03.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/51/2698980f425cda122f5b755a957c3c2db604c0b9a787c6add5aa4649c237/microsoft_agents_activity-0.5.3.tar.gz", hash = "sha256:d80b055591df561df8cebda9e1712012352581a396b36459133a951982b3a760", size = 55892, upload-time = "2025-10-31T15:40:49.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/47/333591538c134b5b4637ffc8ab4f5d0bf1c1b6310e3cfb5adc4002aa5940/microsoft_agents_activity-0.5.1-py3-none-any.whl", hash = "sha256:07562064125f2bc8066c2c8e9a60ff6f038f7413ccd01a9d9b0aa426e47467cd", size = 127817, upload-time = "2025-10-28T19:27:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/9618243e7b6f1f6295642c4e2dfca65b3a37794efbe1bdec15f0a93827d9/microsoft_agents_activity-0.5.3-py3-none-any.whl", hash = "sha256:5ae2447ac47c32f03c614694f520817cd225c9c502ec08b90d448311fb5bf3b4", size = 127861, upload-time = "2025-10-31T15:40:57.628Z" }, ] [[package]] name = "microsoft-agents-copilotstudio-client" -version = "0.5.1" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/53/e6dde964b677358ec7c177c4aa2d408cd31acba4abe3d24c4728c2607b3d/microsoft_agents_copilotstudio_client-0.5.1.tar.gz", hash = "sha256:0b730045b4f8e8f61291279e64e0669868ace39beb63688ec38ba181020f5c3f", size = 11153, upload-time = "2025-10-28T19:27:06.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/22/109164fb585c4baee40d2372c5d76254ec4a28219908f11cd27ac92aa6c1/microsoft_agents_copilotstudio_client-0.5.3.tar.gz", hash = "sha256:a57ea6b3cb47dbb5ad22e59c986208ace6479e35da3f644e6346f4dfd85db57c", size = 11161, upload-time = "2025-10-31T15:40:51.444Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/cd/50576ae8cbb2cd7fe0ebaa3ae882fc69cfb29183a5304cda29ba09084faa/microsoft_agents_copilotstudio_client-0.5.1-py3-none-any.whl", hash = "sha256:115f6aff0e44b97fd23128b7d4d53b6ed10ec54f93494c569c1cb48ac2b8a468", size = 11091, upload-time = "2025-10-28T19:27:14.469Z" }, + { url = "https://files.pythonhosted.org/packages/c4/65/984e139c85657ff0c8df0ed98a167c8b9434f4fd4f32862b4a6490b8c714/microsoft_agents_copilotstudio_client-0.5.3-py3-none-any.whl", hash = "sha256:6a36fce5c8c1a2df6f5142e35b12c69be80959ecff6d60cc309661018c40f00a", size = 11091, upload-time = "2025-10-31T15:40:59.718Z" }, ] [[package]] name = "microsoft-agents-hosting-core" -version = "0.5.1" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3042,9 +3098,9 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/0b/71bc8f2fd673de9f8a0d7e9bef30dd15892d8539c4557129a5aead2c5882/microsoft_agents_hosting_core-0.5.1.tar.gz", hash = "sha256:d9b64095bf7624d4fc9f1d48cea5a3c66cc2dee9e1c3fb6ea3e9b6dfc03ace8f", size = 81277, upload-time = "2025-10-28T19:27:08.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/98/7755c07b2ae5faf3e4dc14b17e44680a600c8b840b3003fb326d5720dea1/microsoft_agents_hosting_core-0.5.3.tar.gz", hash = "sha256:b113d4ea5c9e555bbf61037bb2a1a7a3ce7e5e4a7a0f681a3bd4719ba72ff821", size = 81672, upload-time = "2025-10-31T15:40:53.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/2c/bcb8d66ebfe59cf6093c5eac1fc19a7797b5b80ce3ceaec07f2954a21493/microsoft_agents_hosting_core-0.5.1-py3-none-any.whl", hash = "sha256:10a1f394d8e444f6e2e74ab935f5c0a04ebfa43d136be4658fbaccab1321c37e", size = 120190, upload-time = "2025-10-28T19:27:16.263Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/c9e98475971c9da9cc9ff88195bbfcfae90dba511ebe14610be79f23ab3f/microsoft_agents_hosting_core-0.5.3-py3-none-any.whl", hash = "sha256:8c228a8814dcf1a86dd60e4c7574a2e86078962695fabd693a118097e703e982", size = 120668, upload-time = "2025-10-31T15:41:01.691Z" }, ] [[package]] @@ -3318,11 +3374,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.10.0" +version = "2.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/e5/ef07d31c2e07d99eecac8e14ace5c20aeb00ecba4ed5bb00343136380524/narwhals-2.10.0.tar.gz", hash = "sha256:1c05bbef2048a4045263de7d98c3d06140583eb13d796dd733b2157f05d24485", size = 582423, upload-time = "2025-10-27T17:55:55.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/dc/8db74daf8c2690ec696c1d772a33cc01511559ee8a9e92d7ed85a18e3c22/narwhals-2.10.2.tar.gz", hash = "sha256:ff738a08bc993cbb792266bec15346c1d85cc68fdfe82a23283c3713f78bd354", size = 584954, upload-time = "2025-11-04T16:36:42.281Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/13/024ae0586d901f8a6f99e2d29b4ae217e8ef11d3fd944cdfc3bbde5f2a08/narwhals-2.10.0-py3-none-any.whl", hash = "sha256:baed44e8fc38e800e3a585e3fa9843a7079a6fad5fbffbecee4348d6ac52298c", size = 418077, upload-time = "2025-10-27T17:55:53.709Z" }, + { url = "https://files.pythonhosted.org/packages/47/a9/9e02fa97e421a355fc5e818e9c488080fce04a8e0eebb3ed75a84f041c4a/narwhals-2.10.2-py3-none-any.whl", hash = "sha256:059cd5c6751161b97baedcaf17a514c972af6a70f36a89af17de1a0caf519c43", size = 419573, upload-time = "2025-11-04T16:36:40.574Z" }, ] [[package]] @@ -4003,15 +4059,15 @@ wheels = [ [[package]] name = "plotly" -version = "6.3.1" +version = "6.4.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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/63/961d47c9ffd592a575495891cdcf7875dc0903ebb33ac238935714213789/plotly-6.3.1.tar.gz", hash = "sha256:dd896e3d940e653a7ce0470087e82c2bd903969a55e30d1b01bb389319461bb0", size = 6956460, upload-time = "2025-10-02T16:10:34.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/e6/b768650072837505804bed4790c5449ba348a3b720e27ca7605414e998cd/plotly-6.4.0.tar.gz", hash = "sha256:68c6db2ed2180289ef978f087841148b7efda687552276da15a6e9b92107052a", size = 7012379, upload-time = "2025-11-04T17:59:26.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/93/023955c26b0ce614342d11cc0652f1e45e32393b6ab9d11a664a60e9b7b7/plotly-6.3.1-py3-none-any.whl", hash = "sha256:8b4420d1dcf2b040f5983eed433f95732ed24930e496d36eb70d211923532e64", size = 9833698, upload-time = "2025-10-02T16:10:22.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/ae/89b45ccccfeebc464c9233de5675990f75241b8ee4cd63227800fdf577d1/plotly-6.4.0-py3-none-any.whl", hash = "sha256:a1062eafbdc657976c2eedd276c90e184ccd6c21282a5e9ee8f20efca9c9a4c5", size = 9892458, upload-time = "2025-11-04T17:59:22.622Z" }, ] [[package]] @@ -4086,7 +4142,7 @@ wheels = [ [[package]] name = "posthog" -version = "6.7.11" +version = "6.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4096,9 +4152,9 @@ dependencies = [ { name = "six", 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/32/3668d5e0f8b852fad81770743ee17893854fd8e5f7cea897a0a9199b0370/posthog-6.7.11.tar.gz", hash = "sha256:62db3e97cbd95351fe081c1ea8805393293de6fabad6d2e9024bf940aca4ddbf", size = 120407, upload-time = "2025-10-28T13:06:18.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/26/fbd8a29d094c1b3df109b79f7165ddb20dc37ec1e5b55717585de9ee9b65/posthog-6.8.0.tar.gz", hash = "sha256:40bc3bffe4818d37de63a4f4f13d2e90a78efe14f0d808c962f0ffebc3b15256", size = 122781, upload-time = "2025-11-04T19:43:34.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/00/bf284e0aae5dec7c217c176f291867cfac2f7bfd5692c9ce041e80986fa7/posthog-6.7.11-py3-none-any.whl", hash = "sha256:31421a88437cef2ce20f60c14ee8d298b2e765a6de0617cb95d1fcef54170749", size = 138713, upload-time = "2025-10-28T13:06:17.018Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/970fe48b888c53de5768f67524444c2adf2ea86fba97a672434deb8db971/posthog-6.8.0-py3-none-any.whl", hash = "sha256:b30b3cb06234d9177cecabe6f3e04e34e1e15fe7b60428771a67be57920a6308", size = 141210, upload-time = "2025-11-04T19:43:33.375Z" }, ] [[package]] @@ -4916,109 +4972,109 @@ wheels = [ [[package]] name = "regex" -version = "2025.10.23" +version = "2025.11.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/c8/1d2160d36b11fbe0a61acb7c3c81ab032d9ec8ad888ac9e0a61b85ab99dd/regex-2025.10.23.tar.gz", hash = "sha256:8cbaf8ceb88f96ae2356d01b9adf5e6306fa42fa6f7eab6b97794e37c959ac26", size = 401266, upload-time = "2025-10-21T15:58:20.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/11/849d5d23633a77047465eaae4cc0cbf24ded7aa496c02e8b9710e28b1687/regex-2025.10.23-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:17bbcde374bef1c5fad9b131f0e28a6a24856dd90368d8c0201e2b5a69533daa", size = 487957, upload-time = "2025-10-21T15:54:26.151Z" }, - { url = "https://files.pythonhosted.org/packages/87/12/5985386e7e3200a0d6a6417026d2c758d783a932428a5efc0a42ca1ddf74/regex-2025.10.23-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4e10434279cc8567f99ca6e018e9025d14f2fded2a603380b6be2090f476426", size = 290419, upload-time = "2025-10-21T15:54:28.804Z" }, - { url = "https://files.pythonhosted.org/packages/67/cf/a8615923f962f8fdc41a3a6093a48726955e8b1993f4614b26a41d249f9b/regex-2025.10.23-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c9bb421cbe7012c744a5a56cf4d6c80829c72edb1a2991677299c988d6339c8", size = 288285, upload-time = "2025-10-21T15:54:30.47Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3d/6a3a1e12c86354cd0b3cbf8c3dd6acbe853609ee3b39d47ecd3ce95caf84/regex-2025.10.23-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:275cd1c2ed8c4a78ebfa489618d7aee762e8b4732da73573c3e38236ec5f65de", size = 781458, upload-time = "2025-10-21T15:54:31.978Z" }, - { url = "https://files.pythonhosted.org/packages/46/47/76a8da004489f2700361754859e373b87a53d043de8c47f4d1583fd39d78/regex-2025.10.23-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b426ae7952f3dc1e73a86056d520bd4e5f021397484a6835902fc5648bcacce", size = 850605, upload-time = "2025-10-21T15:54:33.753Z" }, - { url = "https://files.pythonhosted.org/packages/67/05/fa886461f97d45a6f4b209699cb994dc6d6212d6e219d29444dac5005775/regex-2025.10.23-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5cdaf5b6d37c7da1967dbe729d819461aab6a98a072feef65bbcff0a6e60649", size = 898563, upload-time = "2025-10-21T15:54:35.431Z" }, - { url = "https://files.pythonhosted.org/packages/2d/db/3ddd8d01455f23cabad7499f4199de0df92f5e96d39633203ff9d0b592dc/regex-2025.10.23-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bfeff0b08f296ab28b4332a7e03ca31c437ee78b541ebc874bbf540e5932f8d", size = 791535, upload-time = "2025-10-21T15:54:37.269Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ae/0fa5cbf41ca92b6ec3370222fcb6c68b240d68ab10e803d086c03a19fd9e/regex-2025.10.23-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f97236a67307b775f30a74ef722b64b38b7ab7ba3bb4a2508518a5de545459c", size = 782461, upload-time = "2025-10-21T15:54:39.187Z" }, - { url = "https://files.pythonhosted.org/packages/d4/23/70af22a016df11af4def27870eb175c2c7235b72d411ecf75a4b4a422cb6/regex-2025.10.23-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:be19e7de499940cd72475fb8e46ab2ecb1cf5906bebdd18a89f9329afb1df82f", size = 774583, upload-time = "2025-10-21T15:54:41.018Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ee/a54a6851f6905f33d3c4ed64e8737b1d85ed01b5724712530ddc0f9abdb1/regex-2025.10.23-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:883df76ee42d9ecb82b37ff8d01caea5895b3f49630a64d21111078bbf8ef64c", size = 845649, upload-time = "2025-10-21T15:54:42.615Z" }, - { url = "https://files.pythonhosted.org/packages/80/7d/c3ec1cae14e01fab00e38c41ed35f47a853359e95e9c023e9a4381bb122c/regex-2025.10.23-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2e9117d1d35fc2addae6281019ecc70dc21c30014b0004f657558b91c6a8f1a7", size = 836037, upload-time = "2025-10-21T15:54:44.63Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/45771140dd43c4d67c87b54d3728078ed6a96599d9fc7ba6825086236782/regex-2025.10.23-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ff1307f531a5d8cf5c20ea517254551ff0a8dc722193aab66c656c5a900ea68", size = 779705, upload-time = "2025-10-21T15:54:46.08Z" }, - { url = "https://files.pythonhosted.org/packages/b8/95/074e2581760eafce7c816a352b7d3a322536e5b68c346d1a8bacd895545c/regex-2025.10.23-cp310-cp310-win32.whl", hash = "sha256:7888475787cbfee4a7cd32998eeffe9a28129fa44ae0f691b96cb3939183ef41", size = 265663, upload-time = "2025-10-21T15:54:47.854Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c7/a25f56a718847e34d3f1608c72eadeb67653bff1a0411da023dd8f4c647b/regex-2025.10.23-cp310-cp310-win_amd64.whl", hash = "sha256:ec41a905908496ce4906dab20fb103c814558db1d69afc12c2f384549c17936a", size = 277587, upload-time = "2025-10-21T15:54:49.571Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e5/63eb17c6b5deaefd93c2bbb1feae7c0a8d2157da25883a6ca2569cf7a663/regex-2025.10.23-cp310-cp310-win_arm64.whl", hash = "sha256:b2b7f19a764d5e966d5a62bf2c28a8b4093cc864c6734510bdb4aeb840aec5e6", size = 269979, upload-time = "2025-10-21T15:54:51.375Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/74b7cd5cd76b4171f9793042045bb1726f7856dd56e582fc3e058a7a8a5e/regex-2025.10.23-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c531155bf9179345e85032052a1e5fe1a696a6abf9cea54b97e8baefff970fd", size = 487960, upload-time = "2025-10-21T15:54:53.253Z" }, - { url = "https://files.pythonhosted.org/packages/b9/08/854fa4b3b20471d1df1c71e831b6a1aa480281e37791e52a2df9641ec5c6/regex-2025.10.23-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:912e9df4e89d383681268d38ad8f5780d7cccd94ba0e9aa09ca7ab7ab4f8e7eb", size = 290425, upload-time = "2025-10-21T15:54:55.21Z" }, - { url = "https://files.pythonhosted.org/packages/ab/d3/6272b1dd3ca1271661e168762b234ad3e00dbdf4ef0c7b9b72d2d159efa7/regex-2025.10.23-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f375c61bfc3138b13e762fe0ae76e3bdca92497816936534a0177201666f44f", size = 288278, upload-time = "2025-10-21T15:54:56.862Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/c7b365dd9d9bc0a36e018cb96f2ffb60d2ba8deb589a712b437f67de2920/regex-2025.10.23-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e248cc9446081119128ed002a3801f8031e0c219b5d3c64d3cc627da29ac0a33", size = 793289, upload-time = "2025-10-21T15:54:58.352Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fb/b8fbe9aa16cf0c21f45ec5a6c74b4cecbf1a1c0deb7089d4a6f83a9c1caa/regex-2025.10.23-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b52bf9282fdf401e4f4e721f0f61fc4b159b1307244517789702407dd74e38ca", size = 860321, upload-time = "2025-10-21T15:54:59.813Z" }, - { url = "https://files.pythonhosted.org/packages/b0/81/bf41405c772324926a9bd8a640dedaa42da0e929241834dfce0733070437/regex-2025.10.23-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c084889ab2c59765a0d5ac602fd1c3c244f9b3fcc9a65fdc7ba6b74c5287490", size = 907011, upload-time = "2025-10-21T15:55:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fb/5ad6a8b92d3f88f3797b51bb4ef47499acc2d0b53d2fbe4487a892f37a73/regex-2025.10.23-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80e8eb79009bdb0936658c44ca06e2fbbca67792013e3818eea3f5f228971c2", size = 800312, upload-time = "2025-10-21T15:55:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/42/48/b4efba0168a2b57f944205d823f8e8a3a1ae6211a34508f014ec2c712f4f/regex-2025.10.23-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6f259118ba87b814a8ec475380aee5f5ae97a75852a3507cf31d055b01b5b40", size = 782839, upload-time = "2025-10-21T15:55:05.641Z" }, - { url = "https://files.pythonhosted.org/packages/13/2a/c9efb4c6c535b0559c1fa8e431e0574d229707c9ca718600366fcfef6801/regex-2025.10.23-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b8c72a242683dcc72d37595c4f1278dfd7642b769e46700a8df11eab19dfd82", size = 854270, upload-time = "2025-10-21T15:55:07.27Z" }, - { url = "https://files.pythonhosted.org/packages/34/2d/68eecc1bdaee020e8ba549502291c9450d90d8590d0552247c9b543ebf7b/regex-2025.10.23-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8d7b7a0a3df9952f9965342159e0c1f05384c0f056a47ce8b61034f8cecbe83", size = 845771, upload-time = "2025-10-21T15:55:09.477Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cd/a1ae499cf9b87afb47a67316bbf1037a7c681ffe447c510ed98c0aa2c01c/regex-2025.10.23-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:413bfea20a484c524858125e92b9ce6ffdd0a4b97d4ff96b5859aa119b0f1bdd", size = 788778, upload-time = "2025-10-21T15:55:11.396Z" }, - { url = "https://files.pythonhosted.org/packages/38/f9/70765e63f5ea7d43b2b6cd4ee9d3323f16267e530fb2a420d92d991cf0fc/regex-2025.10.23-cp311-cp311-win32.whl", hash = "sha256:f76deef1f1019a17dad98f408b8f7afc4bd007cbe835ae77b737e8c7f19ae575", size = 265666, upload-time = "2025-10-21T15:55:13.306Z" }, - { url = "https://files.pythonhosted.org/packages/9c/1a/18e9476ee1b63aaec3844d8e1cb21842dc19272c7e86d879bfc0dcc60db3/regex-2025.10.23-cp311-cp311-win_amd64.whl", hash = "sha256:59bba9f7125536f23fdab5deeea08da0c287a64c1d3acc1c7e99515809824de8", size = 277600, upload-time = "2025-10-21T15:55:15.087Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1b/c019167b1f7a8ec77251457e3ff0339ed74ca8bce1ea13138dc98309c923/regex-2025.10.23-cp311-cp311-win_arm64.whl", hash = "sha256:b103a752b6f1632ca420225718d6ed83f6a6ced3016dd0a4ab9a6825312de566", size = 269974, upload-time = "2025-10-21T15:55:16.841Z" }, - { url = "https://files.pythonhosted.org/packages/f6/57/eeb274d83ab189d02d778851b1ac478477522a92b52edfa6e2ae9ff84679/regex-2025.10.23-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7a44d9c00f7a0a02d3b777429281376370f3d13d2c75ae74eb94e11ebcf4a7fc", size = 489187, upload-time = "2025-10-21T15:55:18.322Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/7dad43a9b6ea88bf77e0b8b7729a4c36978e1043165034212fd2702880c6/regex-2025.10.23-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b83601f84fde939ae3478bb32a3aef36f61b58c3208d825c7e8ce1a735f143f2", size = 291122, upload-time = "2025-10-21T15:55:20.2Z" }, - { url = "https://files.pythonhosted.org/packages/66/21/38b71e6f2818f0f4b281c8fba8d9d57cfca7b032a648fa59696e0a54376a/regex-2025.10.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec13647907bb9d15fd192bbfe89ff06612e098a5709e7d6ecabbdd8f7908fc45", size = 288797, upload-time = "2025-10-21T15:55:21.932Z" }, - { url = "https://files.pythonhosted.org/packages/be/95/888f069c89e7729732a6d7cca37f76b44bfb53a1e35dda8a2c7b65c1b992/regex-2025.10.23-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78d76dd2957d62501084e7012ddafc5fcd406dd982b7a9ca1ea76e8eaaf73e7e", size = 798442, upload-time = "2025-10-21T15:55:23.747Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/4f903c608faf786627a8ee17c06e0067b5acade473678b69c8094b248705/regex-2025.10.23-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8668e5f067e31a47699ebb354f43aeb9c0ef136f915bd864243098524482ac43", size = 864039, upload-time = "2025-10-21T15:55:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/62/19/2df67b526bf25756c7f447dde554fc10a220fd839cc642f50857d01e4a7b/regex-2025.10.23-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a32433fe3deb4b2d8eda88790d2808fed0dc097e84f5e683b4cd4f42edef6cca", size = 912057, upload-time = "2025-10-21T15:55:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/99/14/9a39b7c9e007968411bc3c843cc14cf15437510c0a9991f080cab654fd16/regex-2025.10.23-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d97d73818c642c938db14c0668167f8d39520ca9d983604575ade3fda193afcc", size = 803374, upload-time = "2025-10-21T15:55:28.9Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f7/3495151dd3ca79949599b6d069b72a61a2c5e24fc441dccc79dcaf708fe6/regex-2025.10.23-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bca7feecc72ee33579e9f6ddf8babbe473045717a0e7dbc347099530f96e8b9a", size = 787714, upload-time = "2025-10-21T15:55:30.628Z" }, - { url = "https://files.pythonhosted.org/packages/28/65/ee882455e051131869957ee8597faea45188c9a98c0dad724cfb302d4580/regex-2025.10.23-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7e24af51e907d7457cc4a72691ec458320b9ae67dc492f63209f01eecb09de32", size = 858392, upload-time = "2025-10-21T15:55:32.322Z" }, - { url = "https://files.pythonhosted.org/packages/53/25/9287fef5be97529ebd3ac79d256159cb709a07eb58d4be780d1ca3885da8/regex-2025.10.23-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d10bcde58bbdf18146f3a69ec46dd03233b94a4a5632af97aa5378da3a47d288", size = 850484, upload-time = "2025-10-21T15:55:34.037Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b4/b49b88b4fea2f14dc73e5b5842755e782fc2e52f74423d6f4adc130d5880/regex-2025.10.23-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:44383bc0c933388516c2692c9a7503e1f4a67e982f20b9a29d2fb70c6494f147", size = 789634, upload-time = "2025-10-21T15:55:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3c/2f8d199d0e84e78bcd6bdc2be9b62410624f6b796e2893d1837ae738b160/regex-2025.10.23-cp312-cp312-win32.whl", hash = "sha256:6040a86f95438a0114bba16e51dfe27f1bc004fd29fe725f54a586f6d522b079", size = 266060, upload-time = "2025-10-21T15:55:37.902Z" }, - { url = "https://files.pythonhosted.org/packages/d7/67/c35e80969f6ded306ad70b0698863310bdf36aca57ad792f45ddc0e2271f/regex-2025.10.23-cp312-cp312-win_amd64.whl", hash = "sha256:436b4c4352fe0762e3bfa34a5567079baa2ef22aa9c37cf4d128979ccfcad842", size = 276931, upload-time = "2025-10-21T15:55:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/f5/a1/4ed147de7d2b60174f758412c87fa51ada15cd3296a0ff047f4280aaa7ca/regex-2025.10.23-cp312-cp312-win_arm64.whl", hash = "sha256:f4b1b1991617055b46aff6f6db24888c1f05f4db9801349d23f09ed0714a9335", size = 270103, upload-time = "2025-10-21T15:55:41.24Z" }, - { url = "https://files.pythonhosted.org/packages/28/c6/195a6217a43719d5a6a12cc192a22d12c40290cecfa577f00f4fb822f07d/regex-2025.10.23-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b7690f95404a1293923a296981fd943cca12c31a41af9c21ba3edd06398fc193", size = 488956, upload-time = "2025-10-21T15:55:42.887Z" }, - { url = "https://files.pythonhosted.org/packages/4c/93/181070cd1aa2fa541ff2d3afcf763ceecd4937b34c615fa92765020a6c90/regex-2025.10.23-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a32d77aeaea58a13230100dd8797ac1a84c457f3af2fdf0d81ea689d5a9105b", size = 290997, upload-time = "2025-10-21T15:55:44.53Z" }, - { url = "https://files.pythonhosted.org/packages/b6/c5/9d37fbe3a40ed8dda78c23e1263002497540c0d1522ed75482ef6c2000f0/regex-2025.10.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b24b29402f264f70a3c81f45974323b41764ff7159655360543b7cabb73e7d2f", size = 288686, upload-time = "2025-10-21T15:55:46.186Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e7/db610ff9f10c2921f9b6ac0c8d8be4681b28ddd40fc0549429366967e61f/regex-2025.10.23-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563824a08c7c03d96856d84b46fdb3bbb7cfbdf79da7ef68725cda2ce169c72a", size = 798466, upload-time = "2025-10-21T15:55:48.24Z" }, - { url = "https://files.pythonhosted.org/packages/90/10/aab883e1fa7fe2feb15ac663026e70ca0ae1411efa0c7a4a0342d9545015/regex-2025.10.23-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0ec8bdd88d2e2659c3518087ee34b37e20bd169419ffead4240a7004e8ed03b", size = 863996, upload-time = "2025-10-21T15:55:50.478Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/8f686dd97a51f3b37d0238cd00a6d0f9ccabe701f05b56de1918571d0d61/regex-2025.10.23-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b577601bfe1d33913fcd9276d7607bbac827c4798d9e14d04bf37d417a6c41cb", size = 912145, upload-time = "2025-10-21T15:55:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ca/639f8cd5b08797bca38fc5e7e07f76641a428cf8c7fca05894caf045aa32/regex-2025.10.23-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c9f2c68ac6cb3de94eea08a437a75eaa2bd33f9e97c84836ca0b610a5804368", size = 803370, upload-time = "2025-10-21T15:55:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/a40725bb76959eddf8abc42a967bed6f4851b39f5ac4f20e9794d7832aa5/regex-2025.10.23-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:89f8b9ea3830c79468e26b0e21c3585f69f105157c2154a36f6b7839f8afb351", size = 787767, upload-time = "2025-10-21T15:55:56.004Z" }, - { url = "https://files.pythonhosted.org/packages/3d/d8/8ee9858062936b0f99656dce390aa667c6e7fb0c357b1b9bf76fb5e2e708/regex-2025.10.23-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:98fd84c4e4ea185b3bb5bf065261ab45867d8875032f358a435647285c722673", size = 858335, upload-time = "2025-10-21T15:55:58.185Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0a/ed5faaa63fa8e3064ab670e08061fbf09e3a10235b19630cf0cbb9e48c0a/regex-2025.10.23-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1e11d3e5887b8b096f96b4154dfb902f29c723a9556639586cd140e77e28b313", size = 850402, upload-time = "2025-10-21T15:56:00.023Z" }, - { url = "https://files.pythonhosted.org/packages/79/14/d05f617342f4b2b4a23561da500ca2beab062bfcc408d60680e77ecaf04d/regex-2025.10.23-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f13450328a6634348d47a88367e06b64c9d84980ef6a748f717b13f8ce64e87", size = 789739, upload-time = "2025-10-21T15:56:01.967Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7b/e8ce8eef42a15f2c3461f8b3e6e924bbc86e9605cb534a393aadc8d3aff8/regex-2025.10.23-cp313-cp313-win32.whl", hash = "sha256:37be9296598a30c6a20236248cb8b2c07ffd54d095b75d3a2a2ee5babdc51df1", size = 266054, upload-time = "2025-10-21T15:56:05.291Z" }, - { url = "https://files.pythonhosted.org/packages/71/2d/55184ed6be6473187868d2f2e6a0708195fc58270e62a22cbf26028f2570/regex-2025.10.23-cp313-cp313-win_amd64.whl", hash = "sha256:ea7a3c283ce0f06fe789365841e9174ba05f8db16e2fd6ae00a02df9572c04c0", size = 276917, upload-time = "2025-10-21T15:56:07.303Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d4/927eced0e2bd45c45839e556f987f8c8f8683268dd3c00ad327deb3b0172/regex-2025.10.23-cp313-cp313-win_arm64.whl", hash = "sha256:d9a4953575f300a7bab71afa4cd4ac061c7697c89590a2902b536783eeb49a4f", size = 270105, upload-time = "2025-10-21T15:56:09.857Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b3/95b310605285573341fc062d1d30b19a54f857530e86c805f942c4ff7941/regex-2025.10.23-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7d6606524fa77b3912c9ef52a42ef63c6cfbfc1077e9dc6296cd5da0da286044", size = 491850, upload-time = "2025-10-21T15:56:11.685Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8f/207c2cec01e34e56db1eff606eef46644a60cf1739ecd474627db90ad90b/regex-2025.10.23-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c037aadf4d64bdc38af7db3dbd34877a057ce6524eefcb2914d6d41c56f968cc", size = 292537, upload-time = "2025-10-21T15:56:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/98/3b/025240af4ada1dc0b5f10d73f3e5122d04ce7f8908ab8881e5d82b9d61b6/regex-2025.10.23-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:99018c331fb2529084a0c9b4c713dfa49fafb47c7712422e49467c13a636c656", size = 290904, upload-time = "2025-10-21T15:56:16.016Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/104ac14e2d3450c43db18ec03e1b96b445a94ae510b60138f00ce2cb7ca1/regex-2025.10.23-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd8aba965604d70306eb90a35528f776e59112a7114a5162824d43b76fa27f58", size = 807311, upload-time = "2025-10-21T15:56:17.818Z" }, - { url = "https://files.pythonhosted.org/packages/19/63/78aef90141b7ce0be8a18e1782f764f6997ad09de0e05251f0d2503a914a/regex-2025.10.23-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:238e67264b4013e74136c49f883734f68656adf8257bfa13b515626b31b20f8e", size = 873241, upload-time = "2025-10-21T15:56:19.941Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a8/80eb1201bb49ae4dba68a1b284b4211ed9daa8e74dc600018a10a90399fb/regex-2025.10.23-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2eb48bd9848d66fd04826382f5e8491ae633de3233a3d64d58ceb4ecfa2113a", size = 914794, upload-time = "2025-10-21T15:56:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d5/1984b6ee93281f360a119a5ca1af6a8ca7d8417861671388bf750becc29b/regex-2025.10.23-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d36591ce06d047d0c0fe2fc5f14bfbd5b4525d08a7b6a279379085e13f0e3d0e", size = 812581, upload-time = "2025-10-21T15:56:24.319Z" }, - { url = "https://files.pythonhosted.org/packages/c4/39/11ebdc6d9927172a64ae237d16763145db6bd45ebb4055c17b88edab72a7/regex-2025.10.23-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5d4ece8628d6e364302006366cea3ee887db397faebacc5dacf8ef19e064cf8", size = 795346, upload-time = "2025-10-21T15:56:26.232Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b4/89a591bcc08b5e436af43315284bd233ba77daf0cf20e098d7af12f006c1/regex-2025.10.23-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:39a7e8083959cb1c4ff74e483eecb5a65d3b3e1d821b256e54baf61782c906c6", size = 868214, upload-time = "2025-10-21T15:56:28.597Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/58ba98409c1dbc8316cdb20dafbc63ed267380a07780cafecaf5012dabc9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:842d449a8fefe546f311656cf8c0d6729b08c09a185f1cad94c756210286d6a8", size = 854540, upload-time = "2025-10-21T15:56:30.875Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f2/4a9e9338d67626e2071b643f828a482712ad15889d7268e11e9a63d6f7e9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d614986dc68506be8f00474f4f6960e03e4ca9883f7df47744800e7d7c08a494", size = 799346, upload-time = "2025-10-21T15:56:32.725Z" }, - { url = "https://files.pythonhosted.org/packages/63/be/543d35c46bebf6f7bf2be538cca74d6585f25714700c36f37f01b92df551/regex-2025.10.23-cp313-cp313t-win32.whl", hash = "sha256:a5b7a26b51a9df473ec16a1934d117443a775ceb7b39b78670b2e21893c330c9", size = 268657, upload-time = "2025-10-21T15:56:34.577Z" }, - { url = "https://files.pythonhosted.org/packages/14/9f/4dd6b7b612037158bb2c9bcaa710e6fb3c40ad54af441b9c53b3a137a9f1/regex-2025.10.23-cp313-cp313t-win_amd64.whl", hash = "sha256:ce81c5544a5453f61cb6f548ed358cfb111e3b23f3cd42d250a4077a6be2a7b6", size = 280075, upload-time = "2025-10-21T15:56:36.767Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/5bd0672aa65d38c8da6747c17c8b441bdb53d816c569e3261013af8e83cf/regex-2025.10.23-cp313-cp313t-win_arm64.whl", hash = "sha256:e9bf7f6699f490e4e43c44757aa179dab24d1960999c84ab5c3d5377714ed473", size = 271219, upload-time = "2025-10-21T15:56:39.033Z" }, - { url = "https://files.pythonhosted.org/packages/73/f6/0caf29fec943f201fbc8822879c99d31e59c1d51a983d9843ee5cf398539/regex-2025.10.23-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5b5cb5b6344c4c4c24b2dc87b0bfee78202b07ef7633385df70da7fcf6f7cec6", size = 488960, upload-time = "2025-10-21T15:56:40.849Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7d/ebb7085b8fa31c24ce0355107cea2b92229d9050552a01c5d291c42aecea/regex-2025.10.23-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a6ce7973384c37bdf0f371a843f95a6e6f4e1489e10e0cf57330198df72959c5", size = 290932, upload-time = "2025-10-21T15:56:42.875Z" }, - { url = "https://files.pythonhosted.org/packages/27/41/43906867287cbb5ca4cee671c3cc8081e15deef86a8189c3aad9ac9f6b4d/regex-2025.10.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2ee3663f2c334959016b56e3bd0dd187cbc73f948e3a3af14c3caaa0c3035d10", size = 288766, upload-time = "2025-10-21T15:56:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9e/ea66132776700fc77a39b1056e7a5f1308032fead94507e208dc6716b7cd/regex-2025.10.23-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2003cc82a579107e70d013482acce8ba773293f2db534fb532738395c557ff34", size = 798884, upload-time = "2025-10-21T15:56:47.178Z" }, - { url = "https://files.pythonhosted.org/packages/d5/99/aed1453687ab63819a443930770db972c5c8064421f0d9f5da9ad029f26b/regex-2025.10.23-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:182c452279365a93a9f45874f7f191ec1c51e1f1eb41bf2b16563f1a40c1da3a", size = 864768, upload-time = "2025-10-21T15:56:49.793Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/732fe747a1304805eb3853ce6337eea16b169f7105a0d0dd9c6a5ffa9948/regex-2025.10.23-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b1249e9ff581c5b658c8f0437f883b01f1edcf424a16388591e7c05e5e9e8b0c", size = 911394, upload-time = "2025-10-21T15:56:52.186Z" }, - { url = "https://files.pythonhosted.org/packages/5e/48/58a1f6623466522352a6efa153b9a3714fc559d9f930e9bc947b4a88a2c3/regex-2025.10.23-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b841698f93db3ccc36caa1900d2a3be281d9539b822dc012f08fc80b46a3224", size = 803145, upload-time = "2025-10-21T15:56:55.142Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f6/7dea79be2681a5574ab3fc237aa53b2c1dfd6bd2b44d4640b6c76f33f4c1/regex-2025.10.23-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:956d89e0c92d471e8f7eee73f73fdff5ed345886378c45a43175a77538a1ffe4", size = 787831, upload-time = "2025-10-21T15:56:57.203Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ad/07b76950fbbe65f88120ca2d8d845047c401450f607c99ed38862904671d/regex-2025.10.23-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5c259cb363299a0d90d63b5c0d7568ee98419861618a95ee9d91a41cb9954462", size = 859162, upload-time = "2025-10-21T15:56:59.195Z" }, - { url = "https://files.pythonhosted.org/packages/41/87/374f3b2021b22aa6a4fc0b750d63f9721e53d1631a238f7a1c343c1cd288/regex-2025.10.23-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:185d2b18c062820b3a40d8fefa223a83f10b20a674bf6e8c4a432e8dfd844627", size = 849899, upload-time = "2025-10-21T15:57:01.747Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/7f7bb17c5a5a9747249807210e348450dab9212a46ae6d23ebce86ba6a2b/regex-2025.10.23-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:281d87fa790049c2b7c1b4253121edd80b392b19b5a3d28dc2a77579cb2a58ec", size = 789372, upload-time = "2025-10-21T15:57:04.018Z" }, - { url = "https://files.pythonhosted.org/packages/c9/dd/9c7728ff544fea09bbc8635e4c9e7c423b11c24f1a7a14e6ac4831466709/regex-2025.10.23-cp314-cp314-win32.whl", hash = "sha256:63b81eef3656072e4ca87c58084c7a9c2b81d41a300b157be635a8a675aacfb8", size = 271451, upload-time = "2025-10-21T15:57:06.266Z" }, - { url = "https://files.pythonhosted.org/packages/48/f8/ef7837ff858eb74079c4804c10b0403c0b740762e6eedba41062225f7117/regex-2025.10.23-cp314-cp314-win_amd64.whl", hash = "sha256:0967c5b86f274800a34a4ed862dfab56928144d03cb18821c5153f8777947796", size = 280173, upload-time = "2025-10-21T15:57:08.206Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d0/d576e1dbd9885bfcd83d0e90762beea48d9373a6f7ed39170f44ed22e336/regex-2025.10.23-cp314-cp314-win_arm64.whl", hash = "sha256:c70dfe58b0a00b36aa04cdb0f798bf3e0adc31747641f69e191109fd8572c9a9", size = 273206, upload-time = "2025-10-21T15:57:10.367Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d0/2025268315e8b2b7b660039824cb7765a41623e97d4cd421510925400487/regex-2025.10.23-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1f5799ea1787aa6de6c150377d11afad39a38afd033f0c5247aecb997978c422", size = 491854, upload-time = "2025-10-21T15:57:12.526Z" }, - { url = "https://files.pythonhosted.org/packages/44/35/5681c2fec5e8b33454390af209c4353dfc44606bf06d714b0b8bd0454ffe/regex-2025.10.23-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a9639ab7540cfea45ef57d16dcbea2e22de351998d614c3ad2f9778fa3bdd788", size = 292542, upload-time = "2025-10-21T15:57:15.158Z" }, - { url = "https://files.pythonhosted.org/packages/5d/17/184eed05543b724132e4a18149e900f5189001fcfe2d64edaae4fbaf36b4/regex-2025.10.23-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08f52122c352eb44c3421dab78b9b73a8a77a282cc8314ae576fcaa92b780d10", size = 290903, upload-time = "2025-10-21T15:57:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/25/d0/5e3347aa0db0de382dddfa133a7b0ae72f24b4344f3989398980b44a3924/regex-2025.10.23-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebf1baebef1c4088ad5a5623decec6b52950f0e4d7a0ae4d48f0a99f8c9cb7d7", size = 807546, upload-time = "2025-10-21T15:57:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/40c589bbdce1be0c55e9f8159789d58d47a22014f2f820cf2b517a5cd193/regex-2025.10.23-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:16b0f1c2e2d566c562d5c384c2b492646be0a19798532fdc1fdedacc66e3223f", size = 873322, upload-time = "2025-10-21T15:57:21.36Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/a7e40c01575ac93360e606278d359f91829781a9f7fb6e5aa435039edbda/regex-2025.10.23-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7ada5d9dceafaab92646aa00c10a9efd9b09942dd9b0d7c5a4b73db92cc7e61", size = 914855, upload-time = "2025-10-21T15:57:24.044Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4b/d55587b192763db3163c3f508b3b67b31bb6f5e7a0e08b83013d0a59500a/regex-2025.10.23-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a36b4005770044bf08edecc798f0e41a75795b9e7c9c12fe29da8d792ef870c", size = 812724, upload-time = "2025-10-21T15:57:26.123Z" }, - { url = "https://files.pythonhosted.org/packages/33/20/18bac334955fbe99d17229f4f8e98d05e4a501ac03a442be8facbb37c304/regex-2025.10.23-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:af7b2661dcc032da1fae82069b5ebf2ac1dfcd5359ef8b35e1367bfc92181432", size = 795439, upload-time = "2025-10-21T15:57:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/67/46/c57266be9df8549c7d85deb4cb82280cb0019e46fff677534c5fa1badfa4/regex-2025.10.23-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb976810ac1416a67562c2e5ba0accf6f928932320fef302e08100ed681b38e", size = 868336, upload-time = "2025-10-21T15:57:30.867Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f3/bd5879e41ef8187fec5e678e94b526a93f99e7bbe0437b0f2b47f9101694/regex-2025.10.23-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:1a56a54be3897d62f54290190fbcd754bff6932934529fbf5b29933da28fcd43", size = 854567, upload-time = "2025-10-21T15:57:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/e6/57/2b6bbdbd2f24dfed5b028033aa17ad8f7d86bb28f1a892cac8b3bc89d059/regex-2025.10.23-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8f3e6d202fb52c2153f532043bbcf618fd177df47b0b306741eb9b60ba96edc3", size = 799565, upload-time = "2025-10-21T15:57:35.153Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ba/a6168f542ba73b151ed81237adf6b869c7b2f7f8d51618111296674e20ee/regex-2025.10.23-cp314-cp314t-win32.whl", hash = "sha256:1fa1186966b2621b1769fd467c7b22e317e6ba2d2cdcecc42ea3089ef04a8521", size = 274428, upload-time = "2025-10-21T15:57:37.996Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a0/c84475e14a2829e9b0864ebf77c3f7da909df9d8acfe2bb540ff0072047c/regex-2025.10.23-cp314-cp314t-win_amd64.whl", hash = "sha256:08a15d40ce28362eac3e78e83d75475147869c1ff86bc93285f43b4f4431a741", size = 284140, upload-time = "2025-10-21T15:57:40.027Z" }, - { url = "https://files.pythonhosted.org/packages/51/33/6a08ade0eee5b8ba79386869fa6f77afeb835b60510f3525db987e2fffc4/regex-2025.10.23-cp314-cp314t-win_arm64.whl", hash = "sha256:a93e97338e1c8ea2649e130dcfbe8cd69bba5e1e163834752ab64dcb4de6d5ed", size = 274497, upload-time = "2025-10-21T15:57:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d6/d788d52da01280a30a3f6268aef2aa71043bff359c618fea4c5b536654d5/regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af", size = 488087, upload-time = "2025-11-03T21:30:47.317Z" }, + { url = "https://files.pythonhosted.org/packages/69/39/abec3bd688ec9bbea3562de0fd764ff802976185f5ff22807bf0a2697992/regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313", size = 290544, upload-time = "2025-11-03T21:30:49.912Z" }, + { url = "https://files.pythonhosted.org/packages/39/b3/9a231475d5653e60002508f41205c61684bb2ffbf2401351ae2186897fc4/regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56", size = 288408, upload-time = "2025-11-03T21:30:51.344Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c5/1929a0491bd5ac2d1539a866768b88965fa8c405f3e16a8cef84313098d6/regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28", size = 781584, upload-time = "2025-11-03T21:30:52.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/16aa16cf5d497ef727ec966f74164fbe75d6516d3d58ac9aa989bc9cdaad/regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7", size = 850733, upload-time = "2025-11-03T21:30:53.825Z" }, + { url = "https://files.pythonhosted.org/packages/e6/49/3294b988855a221cb6565189edf5dc43239957427df2d81d4a6b15244f64/regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32", size = 898691, upload-time = "2025-11-03T21:30:55.575Z" }, + { url = "https://files.pythonhosted.org/packages/14/62/b56d29e70b03666193369bdbdedfdc23946dbe9f81dd78ce262c74d988ab/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391", size = 791662, upload-time = "2025-11-03T21:30:57.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/fc/e4c31d061eced63fbf1ce9d853975f912c61a7d406ea14eda2dd355f48e7/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5", size = 782587, upload-time = "2025-11-03T21:30:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bb/5e30c7394bcf63f0537121c23e796be67b55a8847c3956ae6068f4c70702/regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7", size = 774709, upload-time = "2025-11-03T21:31:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c4/fce773710af81b0cb37cb4ff0947e75d5d17dee304b93d940b87a67fc2f4/regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313", size = 845773, upload-time = "2025-11-03T21:31:01.583Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/9466a7ec4b8ec282077095c6eb50a12a389d2e036581134d4919e8ca518c/regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9", size = 836164, upload-time = "2025-11-03T21:31:03.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/18/82980a60e8ed1594eb3c89eb814fb276ef51b9af7caeab1340bfd8564af6/regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5", size = 779832, upload-time = "2025-11-03T21:31:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/90ab0fdbe6dce064a42015433f9152710139fb04a8b81b4fb57a1cb63ffa/regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec", size = 265802, upload-time = "2025-11-03T21:31:06.581Z" }, + { url = "https://files.pythonhosted.org/packages/34/9d/e9e8493a85f3b1ddc4a5014465f5c2b78c3ea1cbf238dcfde78956378041/regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd", size = 277722, upload-time = "2025-11-03T21:31:08.144Z" }, + { url = "https://files.pythonhosted.org/packages/15/c4/b54b24f553966564506dbf873a3e080aef47b356a3b39b5d5aba992b50db/regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e", size = 270289, upload-time = "2025-11-03T21:31:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/f7/90/4fb5056e5f03a7048abd2b11f598d464f0c167de4f2a51aa868c376b8c70/regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031", size = 488081, upload-time = "2025-11-03T21:31:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/85/23/63e481293fac8b069d84fba0299b6666df720d875110efd0338406b5d360/regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4", size = 290554, upload-time = "2025-11-03T21:31:13.387Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/b101d0262ea293a0066b4522dfb722eb6a8785a8c3e084396a5f2c431a46/regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50", size = 288407, upload-time = "2025-11-03T21:31:14.809Z" }, + { url = "https://files.pythonhosted.org/packages/0c/64/79241c8209d5b7e00577ec9dca35cd493cc6be35b7d147eda367d6179f6d/regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f", size = 793418, upload-time = "2025-11-03T21:31:16.556Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e2/23cd5d3573901ce8f9757c92ca4db4d09600b865919b6d3e7f69f03b1afd/regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118", size = 860448, upload-time = "2025-11-03T21:31:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4c/aecf31beeaa416d0ae4ecb852148d38db35391aac19c687b5d56aedf3a8b/regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2", size = 907139, upload-time = "2025-11-03T21:31:20.753Z" }, + { url = "https://files.pythonhosted.org/packages/61/22/b8cb00df7d2b5e0875f60628594d44dba283e951b1ae17c12f99e332cc0a/regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e", size = 800439, upload-time = "2025-11-03T21:31:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/c4b20330a5cdc7a8eb265f9ce593f389a6a88a0c5f280cf4d978f33966bc/regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0", size = 782965, upload-time = "2025-11-03T21:31:23.598Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4c/ae3e52988ae74af4b04d2af32fee4e8077f26e51b62ec2d12d246876bea2/regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58", size = 854398, upload-time = "2025-11-03T21:31:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/a8b9cf45874eda14b2e275157ce3b304c87e10fb38d9fc26a6e14eb18227/regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab", size = 845897, upload-time = "2025-11-03T21:31:26.427Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fe/1830eb0236be93d9b145e0bd8ab499f31602fe0999b1f19e99955aa8fe20/regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e", size = 788906, upload-time = "2025-11-03T21:31:28.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/dc2577c1f95f188c1e13e2e69d8825a5ac582ac709942f8a03af42ed6e93/regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf", size = 265812, upload-time = "2025-11-03T21:31:29.72Z" }, + { url = "https://files.pythonhosted.org/packages/50/1e/15f08b2f82a9bbb510621ec9042547b54d11e83cb620643ebb54e4eb7d71/regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a", size = 277737, upload-time = "2025-11-03T21:31:31.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fc/6500eb39f5f76c5e47a398df82e6b535a5e345f839581012a418b16f9cc3/regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc", size = 270290, upload-time = "2025-11-03T21:31:33.041Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/18f04cb53e58e3fb107439699bd8375cf5a835eec81084e0bddbd122e4c2/regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41", size = 489312, upload-time = "2025-11-03T21:31:34.343Z" }, + { url = "https://files.pythonhosted.org/packages/78/3f/37fcdd0d2b1e78909108a876580485ea37c91e1acf66d3bb8e736348f441/regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36", size = 291256, upload-time = "2025-11-03T21:31:35.675Z" }, + { url = "https://files.pythonhosted.org/packages/bf/26/0a575f58eb23b7ebd67a45fccbc02ac030b737b896b7e7a909ffe43ffd6a/regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1", size = 288921, upload-time = "2025-11-03T21:31:37.07Z" }, + { url = "https://files.pythonhosted.org/packages/ea/98/6a8dff667d1af907150432cf5abc05a17ccd32c72a3615410d5365ac167a/regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7", size = 798568, upload-time = "2025-11-03T21:31:38.784Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/92c1db4fa4e12733dd5a526c2dd2b6edcbfe13257e135fc0f6c57f34c173/regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69", size = 864165, upload-time = "2025-11-03T21:31:40.559Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/3ad7da8cdee1ce66c7cd37ab5ab05c463a86ffeb52b1a25fe7bd9293b36c/regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48", size = 912182, upload-time = "2025-11-03T21:31:42.002Z" }, + { url = "https://files.pythonhosted.org/packages/84/bd/9ce9f629fcb714ffc2c3faf62b6766ecb7a585e1e885eb699bcf130a5209/regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c", size = 803501, upload-time = "2025-11-03T21:31:43.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0f/8dc2e4349d8e877283e6edd6c12bdcebc20f03744e86f197ab6e4492bf08/regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695", size = 787842, upload-time = "2025-11-03T21:31:45.353Z" }, + { url = "https://files.pythonhosted.org/packages/f9/73/cff02702960bc185164d5619c0c62a2f598a6abff6695d391b096237d4ab/regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98", size = 858519, upload-time = "2025-11-03T21:31:46.814Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/0e8d1ae71e15bc1dc36231c90b46ee35f9d52fab2e226b0e039e7ea9c10a/regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74", size = 850611, upload-time = "2025-11-03T21:31:48.289Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f5/70a5cdd781dcfaa12556f2955bf170cd603cb1c96a1827479f8faea2df97/regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0", size = 789759, upload-time = "2025-11-03T21:31:49.759Z" }, + { url = "https://files.pythonhosted.org/packages/59/9b/7c29be7903c318488983e7d97abcf8ebd3830e4c956c4c540005fcfb0462/regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204", size = 266194, upload-time = "2025-11-03T21:31:51.53Z" }, + { url = "https://files.pythonhosted.org/packages/1a/67/3b92df89f179d7c367be654ab5626ae311cb28f7d5c237b6bb976cd5fbbb/regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9", size = 277069, upload-time = "2025-11-03T21:31:53.151Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/85ba4c066fe5094d35b249c3ce8df0ba623cfd35afb22d6764f23a52a1c5/regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26", size = 270330, upload-time = "2025-11-03T21:31:54.514Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" }, + { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" }, + { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" }, + { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" }, + { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" }, + { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" }, + { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" }, + { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" }, + { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" }, + { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" }, + { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" }, + { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" }, + { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, ] [[package]] @@ -5199,28 +5255,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.2" +version = "0.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, - { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, - { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, - { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, - { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, - { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, - { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, - { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, - { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, - { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, + { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, + { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, ] [[package]] @@ -5714,14 +5770,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.46.2" +version = "0.49.3" 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')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, ] [[package]] From d81b579111a8f447624f3c0b764585f2231540fa Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:30:52 +0900 Subject: [PATCH 10/16] Python: Bump ag-ui package to 1.0.0b251105 for a release. Update changelog. (#1922) * Bump ag-ui package to 1.0.0b251105 for a release. Update changelog. * Fix authors and license-files --- python/CHANGELOG.md | 6 ++++++ python/packages/ag-ui/pyproject.toml | 23 ++++++++++++++++++----- python/uv.lock | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 3ccd587c09..c2246bdc7c 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251105] - 2025-11-05 + +### Added + +- **agent-framework-ag-ui**: Initial release of AG-UI protocol integration for Agent Framework ([#1826](https://github.com/microsoft/agent-framework/pull/1826)) + ## [1.0.0b251104] - 2025-11-04 ### Added diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 019d4705f2..30a0518a39 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,13 +1,26 @@ [project] name = "agent-framework-ag-ui" -version = "0.1.0" +version = "1.0.0b251105" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" -license = { file = "LICENSE" } -authors = [ - { name = "Microsoft", email = "agent-framework@microsoft.com" } -] +license-files = ["LICENSE"] +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] requires-python = ">=3.10" +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] dependencies = [ "agent-framework-core", "ag-ui-protocol>=0.1.9", diff --git a/python/uv.lock b/python/uv.lock index 19df6a548f..eb508ff2ed 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -193,7 +193,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "0.1.0" +version = "1.0.0b251105" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, From 51b32ed1ac919a6a0864c8c6f3cefd3668d6e72c Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 5 Nov 2025 09:33:19 +0100 Subject: [PATCH 11/16] Python: Updates to Tools (#1835) * updated tool samples * mypy and readme fixes * updated call logic * added function invocation config * added include detailed error * added tests * updated FRC exception handling * updated tests * fix oai test * fix name in sample * imporoved tests coverage and removed some dead code paths --- .../packages/core/agent_framework/_clients.py | 6 +- .../packages/core/agent_framework/_tools.py | 448 +++-- .../openai/_assistants_client.py | 2 - .../agent_framework/openai/_chat_client.py | 5 - .../openai/_responses_client.py | 2 - .../core/test_function_invocation_logic.py | 1449 ++++++++++++++++- python/packages/core/tests/core/test_tools.py | 20 + .../tests/openai/test_openai_chat_client.py | 7 +- python/samples/README.md | 11 +- .../samples/getting_started/tools/README.md | 119 ++ .../tools/ai_function_declaration_only.py | 75 + ...on_from_dict_with_dependency_injection.py} | 0 ...y => ai_function_recover_from_failures.py} | 0 ...proval.py => ai_function_with_approval.py} | 0 ... ai_function_with_approval_and_threads.py} | 0 .../tools/ai_function_with_max_exceptions.py | 188 +++ .../tools/ai_function_with_max_invocations.py | 89 + .../tools/ai_functions_in_class.py | 100 ++ .../function_invocation_configuration.py | 58 + 19 files changed, 2460 insertions(+), 119 deletions(-) create mode 100644 python/samples/getting_started/tools/ai_function_declaration_only.py rename python/samples/getting_started/tools/{tool_with_injected_func.py => ai_function_from_dict_with_dependency_injection.py} (100%) rename python/samples/getting_started/tools/{failing_tools.py => ai_function_recover_from_failures.py} (100%) rename python/samples/getting_started/tools/{ai_tool_with_approval.py => ai_function_with_approval.py} (100%) rename python/samples/getting_started/tools/{ai_tool_with_approval_and_threads.py => ai_function_with_approval_and_threads.py} (100%) create mode 100644 python/samples/getting_started/tools/ai_function_with_max_exceptions.py create mode 100644 python/samples/getting_started/tools/ai_function_with_max_invocations.py create mode 100644 python/samples/getting_started/tools/ai_functions_in_class.py create mode 100644 python/samples/getting_started/tools/function_invocation_configuration.py diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index e4b2d53cc6..3cac845ed3 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -19,7 +19,7 @@ from ._middleware import ( ) from ._serialization import SerializationMixin from ._threads import ChatMessageStoreProtocol -from ._tools import ToolProtocol +from ._tools import FUNCTION_INVOKING_CHAT_CLIENT_MARKER, FunctionInvocationConfiguration, ToolProtocol from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, ToolMode, prepare_messages if TYPE_CHECKING: @@ -357,6 +357,10 @@ class BaseChatClient(SerializationMixin, ABC): self.middleware = middleware + self.function_invocation_configuration = ( + FunctionInvocationConfiguration() if hasattr(self.__class__, FUNCTION_INVOKING_CHAT_CLIENT_MARKER) else None + ) + def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: """Convert the instance to a dictionary. diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 22b9921e49..83df62e29d 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -71,6 +71,7 @@ logger = get_logger() __all__ = [ "FUNCTION_INVOKING_CHAT_CLIENT_MARKER", "AIFunction", + "FunctionInvocationConfiguration", "HostedCodeInterpreterTool", "HostedFileSearchTool", "HostedMCPSpecificApproval", @@ -84,7 +85,8 @@ __all__ = [ logger = get_logger() FUNCTION_INVOKING_CHAT_CLIENT_MARKER: Final[str] = "__function_invoking_chat_client__" -DEFAULT_MAX_ITERATIONS: Final[int] = 10 +DEFAULT_MAX_ITERATIONS: Final[int] = 40 +DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol") # region Helpers @@ -156,34 +158,19 @@ def _parse_inputs( # region Tools @runtime_checkable class ToolProtocol(Protocol): - """Represents a generic tool that can be specified to an AI service. + """Represents a generic tool. This protocol defines the interface that all tools must implement to be compatible - with the agent framework. + with the agent framework. It is implemented by various tool classes such as HostedMCPTool, + HostedWebSearchTool, and AIFunction's. A AIFunction is usually created by the `ai_function` decorator. + + Since each connector needs to parse tools differently, users can pass a dict to + specify a service-specific tool when no abstraction is available. Attributes: name: The name of the tool. description: A description of the tool, suitable for use in describing the purpose to a model. additional_properties: Additional properties associated with the tool. - - Examples: - .. code-block:: python - - from agent_framework import ToolProtocol - - - class CustomTool: - def __init__(self, name: str, description: str) -> None: - self.name = name - self.description = description - self.additional_properties = None - - def __str__(self) -> str: - return f"CustomTool(name={self.name})" - - - # Tool now implements ToolProtocol - tool: ToolProtocol = CustomTool("my_tool", "Does something useful") """ name: str @@ -201,22 +188,11 @@ class ToolProtocol(Protocol): class BaseTool(SerializationMixin): """Base class for AI tools, providing common attributes and methods. - This class provides the foundation for creating custom tools with serialization support. + Used as the base class for the various tools in the agent framework, such as HostedMCPTool, + HostedWebSearchTool, and AIFunction. - Examples: - .. code-block:: python - - from agent_framework import BaseTool - - - class MyCustomTool(BaseTool): - def __init__(self, name: str, custom_param: str) -> None: - super().__init__(name=name, description="My custom tool") - self.custom_param = custom_param - - - tool = MyCustomTool(name="custom", custom_param="value") - print(tool) # MyCustomTool(name=custom, description=My custom tool) + Since each connector needs to parse tools differently, this class is not exposed directly to end users. + In most cases, users can pass a dict to specify a service-specific tool when no abstraction is available. """ DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"} @@ -551,6 +527,10 @@ def _default_histogram() -> Histogram: TClass = TypeVar("TClass", bound="SerializationMixin") +class EmptyInputModel(BaseModel): + """An empty input model for functions with no parameters.""" + + class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): """A tool that wraps a Python function to make it callable by AI models. @@ -602,8 +582,10 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): name: str, description: str = "", approval_mode: Literal["always_require", "never_require"] | None = None, + max_invocations: int | None = None, + max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, - func: Callable[..., Awaitable[ReturnT] | ReturnT], + func: Callable[..., Awaitable[ReturnT] | ReturnT] | None = None, input_model: type[ArgsT] | Mapping[str, Any] | None = None, **kwargs: Any, ) -> None: @@ -614,6 +596,10 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): description: A description of the function. approval_mode: Whether or not approval is required to run this tool. Default is that approval is not needed. + max_invocations: The maximum number of times this function can be invoked. + If None, there is no limit. Should be at least 1. + max_invocation_exceptions: The maximum number of exceptions allowed during invocations. + If None, there is no limit. Should be at least 1. additional_properties: Additional properties to set on the function. func: The function to wrap. input_model: The Pydantic model that defines the input parameters for the function. @@ -630,21 +616,56 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): self.func = func self.input_model = self._resolve_input_model(input_model) self.approval_mode = approval_mode or "never_require" + if max_invocations is not None and max_invocations < 1: + raise ValueError("max_invocations must be at least 1 or None.") + if max_invocation_exceptions is not None and max_invocation_exceptions < 1: + raise ValueError("max_invocation_exceptions must be at least 1 or None.") + self.max_invocations = max_invocations + self.invocation_count = 0 + self.max_invocation_exceptions = max_invocation_exceptions + self.invocation_exception_count = 0 self._invocation_duration_histogram = _default_histogram() self.type: Literal["ai_function"] = "ai_function" + @property + def declaration_only(self) -> bool: + """Indicate whether the function is declaration only (i.e., has no implementation).""" + return self.func is None + def _resolve_input_model(self, input_model: type[ArgsT] | Mapping[str, Any] | None) -> type[ArgsT]: - if input_model: - if inspect.isclass(input_model) and issubclass(input_model, BaseModel): - return input_model - if isinstance(input_model, Mapping): - return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model)) - raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.") - return cast(type[ArgsT], _create_input_model_from_func(self.func, self.name)) + """Resolve the input model for the function.""" + if input_model is None: + if self.func is None: + return cast(type[ArgsT], EmptyInputModel) + return cast(type[ArgsT], _create_input_model_from_func(func=self.func, name=self.name)) + if inspect.isclass(input_model) and issubclass(input_model, BaseModel): + return input_model + if isinstance(input_model, Mapping): + return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model)) + raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.") def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]: """Call the wrapped function with the provided arguments.""" - return self.func(*args, **kwargs) + if self.func is None: + raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.") + if self.max_invocations is not None and self.invocation_count >= self.max_invocations: + raise ToolException( + f"Function '{self.name}' has reached its maximum invocation limit, you can no longer use this tool." + ) + if ( + self.max_invocation_exceptions is not None + and self.invocation_exception_count >= self.max_invocation_exceptions + ): + raise ToolException( + f"Function '{self.name}' has reached its maximum exception limit, " + f"you tried to use this tool too many times and it kept failing." + ) + self.invocation_count += 1 + try: + return self.func(*args, **kwargs) + except Exception: + self.invocation_exception_count += 1 + raise async def invoke( self, @@ -664,6 +685,8 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): Raises: TypeError: If arguments is not an instance of the expected input model. """ + if self.declaration_only: + raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.") global OBSERVABILITY_SETTINGS from .observability import OBSERVABILITY_SETTINGS @@ -833,7 +856,7 @@ def _parse_annotation(annotation: Any) -> Any: return annotation -def _create_input_model_from_func(func: Callable[..., Any], tool_name: str) -> type[BaseModel]: +def _create_input_model_from_func(func: Callable[..., Any], name: str) -> type[BaseModel]: """Create a Pydantic model from a function's signature.""" sig = inspect.signature(func) fields = { @@ -844,7 +867,7 @@ def _create_input_model_from_func(func: Callable[..., Any], tool_name: str) -> t for pname, param in sig.parameters.items() if pname not in {"self", "cls"} } - return create_model(f"{tool_name}_input", **fields) # type: ignore[call-overload, no-any-return] + return create_model(f"{name}_input", **fields) # type: ignore[call-overload, no-any-return] # Map JSON Schema types to Pydantic types @@ -907,6 +930,8 @@ def ai_function( name: str | None = None, description: str | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, + max_invocations: int | None = None, + max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, ) -> AIFunction[Any, ReturnT]: ... @@ -918,6 +943,8 @@ def ai_function( name: str | None = None, description: str | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, + max_invocations: int | None = None, + max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, ) -> Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], AIFunction[Any, ReturnT]]: ... @@ -928,6 +955,8 @@ def ai_function( name: str | None = None, description: str | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, + max_invocations: int | None = None, + max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, ) -> AIFunction[Any, ReturnT] | Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], AIFunction[Any, ReturnT]]: """Decorate a function to turn it into a AIFunction that can be passed to models and executed automatically. @@ -940,6 +969,22 @@ def ai_function( with a string description as the second argument. You can also use Pydantic's ``Field`` class for more advanced configuration. + Args: + func: The function to decorate. + + Keyword Args: + name: The name of the function. If not provided, the function's ``__name__`` + attribute will be used. + description: A description of the function. If not provided, the function's + docstring will be used. + approval_mode: Whether or not approval is required to run this tool. + Default is that approval is not needed. + max_invocations: The maximum number of times this function can be invoked. + If None, there is no limit, should be at least 1. + max_invocation_exceptions: The maximum number of exceptions allowed during invocations. + If None, there is no limit, should be at least 1. + additional_properties: Additional properties to set on the function. + Note: When approval_mode is set to "always_require", the function will not be executed until explicit approval is given, this only applies to the auto-invocation flow. @@ -997,6 +1042,8 @@ def ai_function( name=tool_name, description=tool_desc, approval_mode=approval_mode, + max_invocations=max_invocations, + max_invocation_exceptions=max_invocation_exceptions, additional_properties=additional_properties or {}, func=f, ) @@ -1009,10 +1056,123 @@ def ai_function( # region Function Invoking Chat Client +class FunctionInvocationConfiguration(SerializationMixin): + """Configuration for function invocation in chat clients. + + This class is created automatically on every chat client that supports function invocation. + This means that for most cases you can just alter the attributes on the instance, rather then creating a new one. + + Example: + .. code-block:: python + from agent_framework.openai import OpenAIChatClient + + # Create an OpenAI chat client + client = OpenAIChatClient(api_key="your_api_key") + + # Disable function invocation + client.function_invocation_config.enabled = False + + # Set maximum iterations to 10 + client.function_invocation_config.max_iterations = 10 + + # Enable termination on unknown function calls + client.function_invocation_config.terminate_on_unknown_calls = True + + # Add additional tools for function execution + client.function_invocation_config.additional_tools = [my_custom_tool] + + # Enable detailed error information in function results + client.function_invocation_config.include_detailed_errors = True + + # You can also create a new configuration instance if needed + new_config = FunctionInvocationConfiguration( + enabled=True, + max_iterations=20, + terminate_on_unknown_calls=False, + additional_tools=[another_tool], + include_detailed_errors=False, + ) + + # and then assign it to the client + client.function_invocation_config = new_config + + + Attributes: + enabled: Whether function invocation is enabled. + When this is set to False, the client will not attempt to invoke any functions, + because the tool mode will be set to None. + max_iterations: Maximum number of function invocation iterations. + Each request to this client might end up making multiple requests to the model. Each time the model responds + with a function call request, this client might perform that invocation and send the results back to the + model in a new request. This property limits the number of times such a roundtrip is performed. The value + must be at least one, as it includes the initial request. + If you want to fully disable function invocation, use the ``enabled`` property. + The default is 40. + max_consecutive_errors_per_request: Maximum consecutive errors allowed per request. + The maximum number of consecutive function call errors allowed before stopping + further function calls for the request. + The default is 3. + terminate_on_unknown_calls: Whether to terminate on unknown function calls. + When False, call requests to any tools that aren't available to the client + will result in a response message automatically being created and returned to the inner client stating that + the tool couldn't be found. This behavior can help in cases where a model hallucinates a function, but it's + problematic if the model has been made aware of the existence of tools outside of the normal mechanisms, and + requests one of those. ``additional_tools`` can be used to help with that. But if instead the consumer wants + to know about all function call requests that the client can't handle, this can be set to True. Upon + receiving a request to call a function that the client doesn't know about, it will terminate the function + calling loop and return the response, leaving the handling of the function call requests to the consumer of + the client. + additional_tools: Additional tools to include for function execution. + These will not impact the requests sent by the client, which will pass through the + ``tools`` unmodified. However, if the inner client requests the invocation of a tool + that was not in ``ChatOptions.tools``, this ``additional_tools`` collection will also be consulted to look + for a corresponding tool. This is useful when the service might have been pre-configured to be aware of + certain tools that aren't also sent on each individual request. These tools are treated the same as + ``declaration_only`` tools and will be returned to the user. + include_detailed_errors: Whether to include detailed error information in function results. + When set to True, detailed error information such as exception type and message + will be included in the function result content when a function invocation fails. + When False, only a generic error message will be included. + + + """ + + def __init__( + self, + enabled: bool = True, + max_iterations: int = DEFAULT_MAX_ITERATIONS, + max_consecutive_errors_per_request: int = DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST, + terminate_on_unknown_calls: bool = False, + additional_tools: Sequence[ToolProtocol] | None = None, + include_detailed_errors: bool = False, + ) -> None: + """Initialize FunctionInvocationConfiguration. + + Args: + enabled: Whether function invocation is enabled. + max_iterations: Maximum number of function invocation iterations. + max_consecutive_errors_per_request: Maximum consecutive errors allowed per request. + terminate_on_unknown_calls: Whether to terminate on unknown function calls. + additional_tools: Additional tools to include for function execution. + include_detailed_errors: Whether to include detailed error information in function results. + """ + self.enabled = enabled + if max_iterations < 1: + raise ValueError("max_iterations must be at least 1.") + self.max_iterations = max_iterations + if max_consecutive_errors_per_request < 0: + raise ValueError("max_consecutive_errors_per_request must be 0 or more.") + self.max_consecutive_errors_per_request = max_consecutive_errors_per_request + self.terminate_on_unknown_calls = terminate_on_unknown_calls + self.additional_tools = additional_tools or [] + self.include_detailed_errors = include_detailed_errors + + async def _auto_invoke_function( function_call_content: "FunctionCallContent | FunctionApprovalResponseContent", custom_args: dict[str, Any] | None = None, *, + config: FunctionInvocationConfiguration, tool_map: dict[str, AIFunction[BaseModel, Any]], sequence_index: int | None = None, request_index: int | None = None, @@ -1025,6 +1185,7 @@ async def _auto_invoke_function( custom_args: Additional custom arguments to merge with parsed arguments. Keyword Args: + config: The function invocation configuration. tool_map: A mapping of tool names to AIFunction instances. sequence_index: The index of the function call in the sequence. request_index: The index of the request iteration. @@ -1037,29 +1198,33 @@ async def _auto_invoke_function( KeyError: If the requested function is not found in the tool map. """ from ._types import ( - FunctionApprovalRequestContent, - FunctionApprovalResponseContent, - FunctionCallContent, FunctionResultContent, ) + # Note: The scenarios for approval_mode="always_require", declaration_only, and + # terminate_on_unknown_calls are all handled in _try_execute_function_calls before + # this function is called. This function only handles the actual execution of approved, + # non-declaration-only functions. + tool: AIFunction[BaseModel, Any] | None = None - if isinstance(function_call_content, FunctionCallContent): + if function_call_content.type == "function_call": tool = tool_map.get(function_call_content.name) + # Tool should exist because _try_execute_function_calls validates this if tool is None: - raise KeyError(f"No tool or function named '{function_call_content.name}'") - if tool.approval_mode == "always_require": - return FunctionApprovalRequestContent(id=function_call_content.call_id, function_call=function_call_content) + exc = KeyError(f'Function "{function_call_content.name}" not found.') + return FunctionResultContent( + call_id=function_call_content.call_id, + result=f'Error: Requested function "{function_call_content.name}" not found.', + exception=exc, + ) else: - if isinstance(function_call_content, FunctionApprovalResponseContent): - if function_call_content.approved: - tool = tool_map.get(function_call_content.function_call.name) - if tool is None: - # we assume it is a hosted tool - return function_call_content - function_call_content = function_call_content.function_call - else: - raise ToolException("Unapproved tool cannot be executed.") + # Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results + # and never reach this function, so we only handle approved=True cases here. + tool = tool_map.get(function_call_content.function_call.name) + if tool is None: + # we assume it is a hosted tool + return function_call_content + function_call_content = function_call_content.function_call parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {}) @@ -1068,10 +1233,10 @@ async def _auto_invoke_function( try: args = tool.input_model.model_validate(merged_args) except ValidationError as exc: - return FunctionResultContent( - call_id=function_call_content.call_id, - exception=exc, - ) + message = "Error: Argument parsing failed." + if config.include_detailed_errors: + message = f"{message} Exception: {exc}" + return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc) if not middleware_pipeline or ( not hasattr(middleware_pipeline, "has_middlewares") and not middleware_pipeline.has_middlewares ): @@ -1086,10 +1251,10 @@ async def _auto_invoke_function( result=function_result, ) except Exception as exc: - return FunctionResultContent( - call_id=function_call_content.call_id, - exception=exc, - ) + message = "Error: Function failed." + if config.include_detailed_errors: + message = f"{message} Exception: {exc}" + return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc) # Execute through middleware pipeline if available from ._middleware import FunctionInvocationContext @@ -1117,10 +1282,10 @@ async def _auto_invoke_function( result=function_result, ) except Exception as exc: - return FunctionResultContent( - call_id=function_call_content.call_id, - exception=exc, - ) + message = "Error: Function failed." + if config.include_detailed_errors: + message = f"{message} Exception: {exc}" + return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc) def _get_tool_map( @@ -1141,7 +1306,7 @@ def _get_tool_map( return ai_function_list -async def _execute_function_calls( +async def _try_execute_function_calls( custom_args: dict[str, Any], attempt_idx: int, function_calls: Sequence["FunctionCallContent"] | Sequence["FunctionApprovalResponseContent"], @@ -1149,6 +1314,7 @@ async def _execute_function_calls( | Callable[..., Any] \ | MutableMapping[str, Any] \ | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]", + config: FunctionInvocationConfiguration, middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports ) -> Sequence["Contents"]: """Execute multiple function calls concurrently. @@ -1158,22 +1324,33 @@ async def _execute_function_calls( attempt_idx: The index of the current attempt iteration. function_calls: A sequence of FunctionCallContent to execute. tools: The tools available for execution. + config: Configuration for function invocation. middleware_pipeline: Optional middleware pipeline to apply during execution. Returns: - A list of Contents containing the results of each function call. + A list of Contents containing the results of each function call, + or the approval requests if any function requires approval, + or the original function calls if any are declaration only. """ from ._types import FunctionApprovalRequestContent, FunctionCallContent tool_map = _get_tool_map(tools) approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"] + declaration_only = [tool_name for tool_name, tool in tool_map.items() if tool.declaration_only] + additional_tool_names = [tool.name for tool in config.additional_tools] if config.additional_tools else [] # check if any are calling functions that need approval # if so, we return approval request for all approval_needed = False + declaration_only_flag = False for fcc in function_calls: if isinstance(fcc, FunctionCallContent) and fcc.name in approval_tools: approval_needed = True break + if isinstance(fcc, FunctionCallContent) and (fcc.name in declaration_only or fcc.name in additional_tool_names): + declaration_only_flag = True + break + if config.terminate_on_unknown_calls and isinstance(fcc, FunctionCallContent) and fcc.name not in tool_map: + raise KeyError(f'Error: Requested function "{fcc.name}" not found.') if approval_needed: # approval can only be needed for Function Call Contents, not Approval Responses. return [ @@ -1181,6 +1358,9 @@ async def _execute_function_calls( for fcc in function_calls if isinstance(fcc, FunctionCallContent) ] + if declaration_only_flag: + # return the declaration only tools to the user, since we cannot execute them. + return [fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)] # Run all function calls concurrently return await asyncio.gather(*[ @@ -1191,6 +1371,7 @@ async def _execute_function_calls( sequence_index=seq_idx, request_index=attempt_idx, middleware_pipeline=middleware_pipeline, + config=config, ) for seq_idx, function_call in enumerate(function_calls) ]) @@ -1334,17 +1515,17 @@ def _handle_function_calls_response( # because the underlying function may not preserve it in kwargs stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline") - # Get max_iterations from instance additional_properties or class attribute - instance_max_iterations: int = DEFAULT_MAX_ITERATIONS - if hasattr(self, "additional_properties") and self.additional_properties: - instance_max_iterations = self.additional_properties.get("max_iterations", DEFAULT_MAX_ITERATIONS) - elif hasattr(self.__class__, "MAX_ITERATIONS"): - instance_max_iterations = getattr(self.__class__, "MAX_ITERATIONS", DEFAULT_MAX_ITERATIONS) + # Get the config for function invocation (not part of ChatClientProtocol, hence getattr) + config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None) + if not config: + # Default config if not set + config = FunctionInvocationConfiguration() + errors_in_a_row: int = 0 prepped_messages = prepare_messages(messages) response: "ChatResponse | None" = None fcc_messages: "list[ChatMessage]" = [] - for attempt_idx in range(instance_max_iterations): + for attempt_idx in range(config.max_iterations if config.enabled else 0): fcc_todo = _collect_approval_responses(prepped_messages) if fcc_todo: tools = _extract_tools(kwargs) @@ -1352,13 +1533,29 @@ def _handle_function_calls_response( approved_responses = [resp for resp in fcc_todo.values() if resp.approved] approved_function_results: list[Contents] = [] if approved_responses: - approved_function_results = await _execute_function_calls( + approved_function_results = await _try_execute_function_calls( custom_args=kwargs, attempt_idx=attempt_idx, function_calls=approved_responses, tools=tools, # type: ignore middleware_pipeline=stored_middleware_pipeline, + config=config, ) + if any( + fcr.exception is not None + for fcr in approved_function_results + if isinstance(fcr, FunctionResultContent) + ): + errors_in_a_row += 1 + # no need to reset the counter here, since this is the start of a new attempt. + if errors_in_a_row >= config.max_consecutive_errors_per_request: + logger.warning( + "Maximum consecutive function call errors reached (%d). " + "Stopping further function calls for this request.", + config.max_consecutive_errors_per_request, + ) + # break out of the loop and do the fallback response + break _replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results) response = await func(self, messages=prepped_messages, **kwargs) @@ -1381,15 +1578,15 @@ def _handle_function_calls_response( if function_calls and tools: # Use the stored middleware pipeline instead of extracting from kwargs # because kwargs may have been modified by the underlying function - function_call_results: list[Contents] = await _execute_function_calls( + function_call_results: list[Contents] = await _try_execute_function_calls( custom_args=kwargs, attempt_idx=attempt_idx, function_calls=function_calls, tools=tools, # type: ignore middleware_pipeline=stored_middleware_pipeline, + config=config, ) - - # Check if we have approval requests in the results + # Check if we have approval requests or function calls (not results) in the results if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results): # Add approval requests to the existing assistant message (with tool_calls) # instead of creating a separate tool message @@ -1402,6 +1599,26 @@ def _handle_function_calls_response( result_message = ChatMessage(role="assistant", contents=function_call_results) response.messages.append(result_message) return response + if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results): + # the function calls are already in the response, so we just continue + return response + + if any( + fcr.exception is not None + for fcr in function_call_results + if isinstance(fcr, FunctionResultContent) + ): + errors_in_a_row += 1 + if errors_in_a_row >= config.max_consecutive_errors_per_request: + logger.warning( + "Maximum consecutive function call errors reached (%d). " + "Stopping further function calls for this request.", + config.max_consecutive_errors_per_request, + ) + # break out of the loop and do the fallback response + break + else: + errors_in_a_row = 0 # add a single ChatMessage to the response with the results result_message = ChatMessage(role="tool", contents=function_call_results) @@ -1482,16 +1699,16 @@ def _handle_function_calls_streaming_response( # because the underlying function may not preserve it in kwargs stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline") - # Get max_iterations from instance additional_properties or class attribute - instance_max_iterations: int = DEFAULT_MAX_ITERATIONS - if hasattr(self, "additional_properties") and self.additional_properties: - instance_max_iterations = self.additional_properties.get("max_iterations", DEFAULT_MAX_ITERATIONS) - elif hasattr(self.__class__, "MAX_ITERATIONS"): - instance_max_iterations = getattr(self.__class__, "MAX_ITERATIONS", DEFAULT_MAX_ITERATIONS) + # Get the config for function invocation (not part of ChatClientProtocol, hence getattr) + config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None) + if not config: + # Default config if not set + config = FunctionInvocationConfiguration() + errors_in_a_row: int = 0 prepped_messages = prepare_messages(messages) fcc_messages: "list[ChatMessage]" = [] - for attempt_idx in range(instance_max_iterations): + for attempt_idx in range(config.max_iterations if config.enabled else 0): fcc_todo = _collect_approval_responses(prepped_messages) if fcc_todo: tools = _extract_tools(kwargs) @@ -1499,13 +1716,21 @@ def _handle_function_calls_streaming_response( approved_responses = [resp for resp in fcc_todo.values() if resp.approved] approved_function_results: list[Contents] = [] if approved_responses: - approved_function_results = await _execute_function_calls( + approved_function_results = await _try_execute_function_calls( custom_args=kwargs, attempt_idx=attempt_idx, function_calls=approved_responses, tools=tools, # type: ignore middleware_pipeline=stored_middleware_pipeline, + config=config, ) + if any( + fcr.exception is not None + for fcr in approved_function_results + if isinstance(fcr, FunctionResultContent) + ): + errors_in_a_row += 1 + # no need to reset the counter here, since this is the start of a new attempt. _replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results) all_updates: list["ChatResponseUpdate"] = [] @@ -1551,15 +1776,16 @@ def _handle_function_calls_streaming_response( if function_calls and tools: # Use the stored middleware pipeline instead of extracting from kwargs # because kwargs may have been modified by the underlying function - function_call_results: list[Contents] = await _execute_function_calls( + function_call_results: list[Contents] = await _try_execute_function_calls( custom_args=kwargs, attempt_idx=attempt_idx, function_calls=function_calls, tools=tools, # type: ignore middleware_pipeline=stored_middleware_pipeline, + config=config, ) - # Check if we have approval requests in the results + # Check if we have approval requests or function calls (not results) in the results if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results): # Add approval requests to the existing assistant message (with tool_calls) # instead of creating a separate tool message @@ -1575,6 +1801,26 @@ def _handle_function_calls_streaming_response( yield ChatResponseUpdate(contents=function_call_results, role="assistant") response.messages.append(result_message) return + if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results): + # the function calls were already yielded. + return + + if any( + fcr.exception is not None + for fcr in function_call_results + if isinstance(fcr, FunctionResultContent) + ): + errors_in_a_row += 1 + if errors_in_a_row >= config.max_consecutive_errors_per_request: + logger.warning( + "Maximum consecutive function call errors reached (%d). " + "Stopping further function calls for this request.", + config.max_consecutive_errors_per_request, + ) + # break out of the loop and do the fallback response + break + else: + errors_in_a_row = 0 # add a single ChatMessage to the response with the results result_message = ChatMessage(role="tool", contents=function_call_results) @@ -1648,10 +1894,6 @@ def use_function_invocation( if getattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER, False): return chat_client - # Set MAX_ITERATIONS as a class variable if not already set - if not hasattr(chat_client, "MAX_ITERATIONS"): - chat_client.MAX_ITERATIONS = DEFAULT_MAX_ITERATIONS # type: ignore - try: chat_client.get_response = _handle_function_calls_response( # type: ignore func=chat_client.get_response, # type: ignore diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 239efb76e3..8a28075e62 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -502,8 +502,6 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): tool_outputs = [] if function_result_content.result: output = prepare_function_call_results(function_result_content.result) - elif function_result_content.exception: - output = "Error: " + str(function_result_content.exception) else: output = "No output received." tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output)) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 70a37894d4..e6a4087508 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -380,11 +380,6 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): args["tool_call_id"] = content.call_id if content.result is not None: args["content"] = prepare_function_call_results(content.result) - elif content.exception is not None: - # Send the exception message to the model - # Otherwise we won't have any channels to talk to OpenAI - # TODO(yuge): This should ideally be customizable - args["content"] = "Error: " + str(content.exception) case _: if "content" not in args: args["content"] = [] diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 0d422f33bc..279180e0ee 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -501,8 +501,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): } if content.result: args["output"] = prepare_function_call_results(content.result) - if content.exception: - args["output"] = "Error: " + str(content.exception) return args case FunctionApprovalRequestContent(): return { 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 2812f19c9d..77b95d98a2 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -605,7 +605,7 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): ] # Set max_iterations to 1 in additional_properties - chat_client_base.additional_properties = {"max_iterations": 1} + chat_client_base.function_invocation_configuration.max_iterations = 1 response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func]) @@ -615,3 +615,1450 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): # 3. Fall back to asking for a plain answer with tool_choice="none" assert exec_counter == 1 # Only first function executed assert response.messages[-1].text == "I broke out of the function invocation loop..." # Failsafe response + + +async def test_function_invocation_config_enabled_false(chat_client_base: ChatClientProtocol): + """Test that setting enabled=False disables function invocation.""" + exec_counter = 0 + + @ai_function(name="test_function") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.run_responses = [ + ChatResponse(messages=ChatMessage(role="assistant", text="response without function calling")), + ] + + # Disable function invocation + chat_client_base.function_invocation_configuration.enabled = False + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func]) + + # Function should not be executed - when enabled=False, the loop doesn't run + assert exec_counter == 0 + # The response should be from the mock client + assert len(response.messages) > 0 + + +async def test_function_invocation_config_max_consecutive_errors(chat_client_base: ChatClientProtocol): + """Test that max_consecutive_errors_per_request limits error retries.""" + + @ai_function(name="error_function") + def error_func(arg1: str) -> str: + raise ValueError("Function error") + + # Set up multiple function call responses that will all error + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="error_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="2", name="error_function", arguments='{"arg1": "value2"}')], + ) + ), + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="3", name="error_function", arguments='{"arg1": "value3"}')], + ) + ), + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="4", name="error_function", arguments='{"arg1": "value4"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="final response")), + ] + + # Set max_consecutive_errors to 2 + chat_client_base.function_invocation_configuration.max_consecutive_errors_per_request = 2 + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func]) + + # Should stop after 2 consecutive errors and force a non-tool response + error_results = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, FunctionResultContent) and content.exception + ] + # The first call errors, then the second call errors, hitting the limit + # So we get 2 function calls with errors, but the responses show the behavior stopped + assert len(error_results) >= 1 # At least one error occurred + # Should have stopped making new function calls after hitting the error limit + function_calls = [ + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionCallContent) + ] + # Should have made at most 2 function calls before stopping + assert len(function_calls) <= 2 + + +async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_client_base: ChatClientProtocol): + """Test that terminate_on_unknown_calls=False returns error message for unknown functions.""" + exec_counter = 0 + + @ai_function(name="known_function") + def known_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="unknown_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set terminate_on_unknown_calls to False (default) + chat_client_base.function_invocation_configuration.terminate_on_unknown_calls = False + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[known_func]) + + # Should have a result message indicating the tool wasn't found + assert len(response.messages) == 3 + assert isinstance(response.messages[1].contents[0], FunctionResultContent) + result_str = response.messages[1].contents[0].result or response.messages[1].contents[0].exception or "" + assert "not found" in result_str.lower() + assert exec_counter == 0 # Known function not executed + + +async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_client_base: ChatClientProtocol): + """Test that terminate_on_unknown_calls=True stops execution on unknown functions.""" + exec_counter = 0 + + @ai_function(name="known_function") + def known_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="unknown_function", arguments='{"arg1": "value1"}')], + ) + ), + ] + + # Set terminate_on_unknown_calls to True + chat_client_base.function_invocation_configuration.terminate_on_unknown_calls = True + + # Should raise an exception when encountering an unknown function + with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'): + await chat_client_base.get_response("hello", tool_choice="auto", tools=[known_func]) + + assert exec_counter == 0 + + +async def test_function_invocation_config_additional_tools(chat_client_base: ChatClientProtocol): + """Test that additional_tools are available but treated as declaration_only.""" + exec_counter_visible = 0 + exec_counter_hidden = 0 + + @ai_function(name="visible_function") + def visible_func(arg1: str) -> str: + nonlocal exec_counter_visible + exec_counter_visible += 1 + return f"Visible {arg1}" + + @ai_function(name="hidden_function") + def hidden_func(arg1: str) -> str: + nonlocal exec_counter_hidden + exec_counter_hidden += 1 + return f"Hidden {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="hidden_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Add hidden_func to additional_tools + chat_client_base.function_invocation_configuration.additional_tools = [hidden_func] + + # Only pass visible_func in the tools parameter + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[visible_func]) + + # Additional tools are treated as declaration_only, so not executed + # The function call should be in the messages but not executed + assert exec_counter_hidden == 0 + assert exec_counter_visible == 0 + # Should have the function call in messages (declaration_only behavior) + function_calls = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, FunctionCallContent) and content.name == "hidden_function" + ] + assert len(function_calls) >= 1 + + +async def test_function_invocation_config_include_detailed_errors_false(chat_client_base: ChatClientProtocol): + """Test that include_detailed_errors=False returns generic error messages.""" + + @ai_function(name="error_function") + def error_func(arg1: str) -> str: + raise ValueError("Specific error message that should not appear") + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="error_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set include_detailed_errors to False (default) + chat_client_base.function_invocation_configuration.include_detailed_errors = False + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func]) + + # Should have a generic error message + error_result = next( + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Specific error message" not in error_result.result + assert "Error:" in error_result.result # Generic error prefix + + +async def test_function_invocation_config_include_detailed_errors_true(chat_client_base: ChatClientProtocol): + """Test that include_detailed_errors=True returns detailed error information.""" + + @ai_function(name="error_function") + def error_func(arg1: str) -> str: + raise ValueError("Specific error message that should appear") + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="error_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set include_detailed_errors to True + chat_client_base.function_invocation_configuration.include_detailed_errors = True + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func]) + + # Should have detailed error message + error_result = next( + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Specific error message that should appear" in error_result.result + # The error format includes "Function failed. Exception:" prefix + assert "Exception:" in error_result.result + + +async def test_function_invocation_config_validation_max_iterations(): + """Test that max_iterations validation works correctly.""" + from agent_framework import FunctionInvocationConfiguration + + # Valid values + config = FunctionInvocationConfiguration(max_iterations=1) + assert config.max_iterations == 1 + + config = FunctionInvocationConfiguration(max_iterations=100) + assert config.max_iterations == 100 + + # Invalid value (less than 1) + with pytest.raises(ValueError, match="max_iterations must be at least 1"): + FunctionInvocationConfiguration(max_iterations=0) + + with pytest.raises(ValueError, match="max_iterations must be at least 1"): + FunctionInvocationConfiguration(max_iterations=-1) + + +async def test_function_invocation_config_validation_max_consecutive_errors(): + """Test that max_consecutive_errors_per_request validation works correctly.""" + from agent_framework import FunctionInvocationConfiguration + + # Valid values + config = FunctionInvocationConfiguration(max_consecutive_errors_per_request=0) + assert config.max_consecutive_errors_per_request == 0 + + config = FunctionInvocationConfiguration(max_consecutive_errors_per_request=5) + assert config.max_consecutive_errors_per_request == 5 + + # Invalid value (less than 0) + with pytest.raises(ValueError, match="max_consecutive_errors_per_request must be 0 or more"): + FunctionInvocationConfiguration(max_consecutive_errors_per_request=-1) + + +async def test_argument_validation_error_with_detailed_errors(chat_client_base: ChatClientProtocol): + """Test that argument validation errors include details when include_detailed_errors=True.""" + + @ai_function(name="typed_function") + def typed_func(arg1: int) -> str: # Expects int, not str + return f"Got {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="typed_function", arguments='{"arg1": "not_an_int"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set include_detailed_errors to True + chat_client_base.function_invocation_configuration.include_detailed_errors = True + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[typed_func]) + + # Should have detailed validation error + error_result = next( + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Argument parsing failed" in error_result.result + assert "Exception:" in error_result.result # Detailed error included + + +async def test_argument_validation_error_without_detailed_errors(chat_client_base: ChatClientProtocol): + """Test that argument validation errors are generic when include_detailed_errors=False.""" + + @ai_function(name="typed_function") + def typed_func(arg1: int) -> str: # Expects int, not str + return f"Got {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="typed_function", arguments='{"arg1": "not_an_int"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set include_detailed_errors to False (default) + chat_client_base.function_invocation_configuration.include_detailed_errors = False + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[typed_func]) + + # Should have generic validation error + error_result = next( + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Argument parsing failed" in error_result.result + assert "Exception:" not in error_result.result # No detailed error + + +async def test_hosted_tool_approval_response(chat_client_base: ChatClientProtocol): + """Test handling of approval responses for hosted tools (tools not in tool_map).""" + from agent_framework import FunctionApprovalResponseContent + + @ai_function(name="local_function") + def local_func(arg1: str) -> str: + return f"Local {arg1}" + + # Create an approval response for a hosted tool that's not in our tool_map + hosted_function_call = FunctionCallContent( + call_id="hosted_1", name="hosted_function", arguments='{"arg1": "value"}' + ) + approval_response = FunctionApprovalResponseContent( + id="approval_1", + function_call=hosted_function_call, + approved=True, + ) + + chat_client_base.run_responses = [ + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Send the approval response + response = await chat_client_base.get_response( + [ChatMessage(role="user", contents=[approval_response])], + tool_choice="auto", + tools=[local_func], + ) + + # The hosted tool approval should be returned as-is (not executed) + # Check that we got a response without errors + assert response is not None + + +async def test_unapproved_tool_execution_raises_exception(chat_client_base: ChatClientProtocol): + """Test that attempting to execute an unapproved tool raises ToolException.""" + from agent_framework import FunctionApprovalResponseContent + + @ai_function(name="test_function", approval_mode="always_require") + def test_func(arg1: str) -> str: + return f"Result {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}'), + ], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Get approval request + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[test_func]) + + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + + # Create a rejection response (approved=False) + rejection_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=False, + ) + + # Continue conversation with rejection + all_messages = response1.messages + [ChatMessage(role="user", contents=[rejection_response])] + + # This should handle the rejection gracefully (not raise ToolException to user) + await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[test_func]) + + # Should have a rejection result + rejection_result = next( + ( + content + for msg in all_messages + for content in msg.contents + if isinstance(content, FunctionResultContent) + and "rejected" in (content.result or content.exception or "").lower() + ), + None, + ) + assert rejection_result is not None + + +async def test_approved_function_call_with_error_without_detailed_errors(chat_client_base: ChatClientProtocol): + """Test that approved functions that raise errors return generic error messages. + + When include_detailed_errors=False. + """ + from agent_framework import FunctionApprovalResponseContent + + exec_counter = 0 + + @ai_function(name="error_func", approval_mode="always_require") + def error_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + raise ValueError("Specific error from approved function") + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="error_func", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set include_detailed_errors to False (default) + chat_client_base.function_invocation_configuration.include_detailed_errors = False + + # Get approval request + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func]) + + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + + # Approve the function + approval_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=True, + ) + + all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + + # Execute the approved function (which will error) + await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[error_func]) + + # Should have executed the function + assert exec_counter == 1 + + # Should have an error result with generic message + error_result = next( + ( + content + for msg in all_messages + for content in msg.contents + if isinstance(content, FunctionResultContent) and content.exception is not None + ), + None, + ) + assert error_result is not None + assert error_result.result is not None + assert "Error: Function failed." in error_result.result + assert "Specific error from approved function" not in error_result.result # Detail not included + + +async def test_approved_function_call_with_error_with_detailed_errors(chat_client_base: ChatClientProtocol): + """Test that approved functions that raise errors return detailed error messages. + + When include_detailed_errors=True. + """ + from agent_framework import FunctionApprovalResponseContent + + exec_counter = 0 + + @ai_function(name="error_func", approval_mode="always_require") + def error_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + raise ValueError("Specific error from approved function") + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="error_func", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set include_detailed_errors to True + chat_client_base.function_invocation_configuration.include_detailed_errors = True + + # Get approval request + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func]) + + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + + # Approve the function + approval_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=True, + ) + + all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + + # Execute the approved function (which will error) + await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[error_func]) + + # Should have executed the function + assert exec_counter == 1 + + # Should have an error result with detailed message + error_result = next( + ( + content + for msg in all_messages + for content in msg.contents + if isinstance(content, FunctionResultContent) and content.exception is not None + ), + None, + ) + assert error_result is not None + assert error_result.result is not None + assert "Error: Function failed." in error_result.result + assert "Exception:" in error_result.result + assert "Specific error from approved function" in error_result.result # Detail included + + +async def test_approved_function_call_with_validation_error(chat_client_base: ChatClientProtocol): + """Test that approved functions with validation errors are handled correctly.""" + from agent_framework import FunctionApprovalResponseContent + + exec_counter = 0 + + @ai_function(name="typed_func", approval_mode="always_require") + def typed_func(arg1: int) -> str: # Expects int, not str + nonlocal exec_counter + exec_counter += 1 + return f"Got {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="typed_func", arguments='{"arg1": "not_an_int"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Set include_detailed_errors to True to see validation details + chat_client_base.function_invocation_configuration.include_detailed_errors = True + + # Get approval request + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[typed_func]) + + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + + # Approve the function (even though it will fail validation) + approval_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=True, + ) + + all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + + # Execute the approved function (which will fail validation) + await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[typed_func]) + + # Should NOT have executed the function (validation failed before execution) + assert exec_counter == 0 + + # Should have a validation error result + error_result = next( + ( + content + for msg in all_messages + for content in msg.contents + if isinstance(content, FunctionResultContent) and content.exception is not None + ), + None, + ) + assert error_result is not None + assert error_result.result is not None + assert "Argument parsing failed" in error_result.result + + +async def test_approved_function_call_successful_execution(chat_client_base: ChatClientProtocol): + """Test that approved functions execute successfully when no errors occur.""" + from agent_framework import FunctionApprovalResponseContent + + exec_counter = 0 + + @ai_function(name="success_func", approval_mode="always_require") + def success_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Success {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="success_func", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Get approval request + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[success_func]) + + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + + # Approve the function + approval_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=True, + ) + + all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + + # Execute the approved function + await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[success_func]) + + # Should have executed successfully + assert exec_counter == 1 + + # Should have a success result + success_result = next( + ( + content + for msg in all_messages + for content in msg.contents + if isinstance(content, FunctionResultContent) and content.exception is None + ), + None, + ) + assert success_result is not None + assert success_result.result == "Success value1" + + +async def test_declaration_only_tool_not_executed(chat_client_base: ChatClientProtocol): + """Test that declaration_only tools are not executed.""" + exec_counter = 0 + + @ai_function(name="declaration_func") + def declaration_func_inner(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Result {arg1}" + + # Create a new AIFunction with declaration_only set + from agent_framework import AIFunction + + declaration_func = AIFunction( + name="declaration_func", + func=declaration_func_inner, + additional_properties={"declaration_only": True}, + ) + # Set declaration_only on the instance + object.__setattr__(declaration_func, "_declaration_only", True) + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="declaration_func", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[declaration_func]) + + # Function should NOT be executed + assert exec_counter == 0 + # Should have the function call in messages but not a result + function_calls = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, FunctionCallContent) and content.name == "declaration_func" + ] + assert len(function_calls) >= 1 + + +async def test_multiple_function_calls_parallel_execution(chat_client_base: ChatClientProtocol): + """Test that multiple function calls are executed in parallel.""" + import asyncio + + exec_order = [] + + @ai_function(name="func1") + async def func1(arg1: str) -> str: + exec_order.append("func1_start") + await asyncio.sleep(0.01) # Small delay + exec_order.append("func1_end") + return f"Result1 {arg1}" + + @ai_function(name="func2") + async def func2(arg1: str) -> str: + exec_order.append("func2_start") + await asyncio.sleep(0.01) # Small delay + exec_order.append("func2_end") + return f"Result2 {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent(call_id="1", name="func1", arguments='{"arg1": "value1"}'), + FunctionCallContent(call_id="2", name="func2", arguments='{"arg1": "value2"}'), + ], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func1, func2]) + + # Both functions should have been executed + assert "func1_start" in exec_order + assert "func1_end" in exec_order + assert "func2_start" in exec_order + assert "func2_end" in exec_order + + # Should have results for both + results = [ + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionResultContent) + ] + assert len(results) == 2 + + +async def test_callable_function_converted_to_ai_function(chat_client_base: ChatClientProtocol): + """Test that plain callable functions are converted to AIFunction.""" + exec_counter = 0 + + def plain_function(arg1: str) -> str: + """A plain function without decorator.""" + nonlocal exec_counter + exec_counter += 1 + return f"Plain {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="plain_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Pass plain function (will be auto-converted) + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[plain_function]) + + # Function should be executed + assert exec_counter == 1 + result = next( + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionResultContent) + ) + assert result.result == "Plain value1" + + +async def test_conversation_id_handling(chat_client_base: ChatClientProtocol): + """Test that conversation_id is properly handled and messages are cleared.""" + + @ai_function(name="test_function") + def test_func(arg1: str) -> str: + return f"Result {arg1}" + + # Return a response with a conversation_id + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')], + ), + conversation_id="conv_123", # Simulate service-side thread + ), + ChatResponse( + messages=ChatMessage(role="assistant", text="done"), + conversation_id="conv_123", + ), + ] + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[test_func]) + + # Should have executed the function + results = [ + content for msg in response.messages for content in msg.contents if isinstance(content, FunctionResultContent) + ] + assert len(results) >= 1 + assert response.conversation_id == "conv_123" + + +async def test_function_result_appended_to_existing_assistant_message(chat_client_base: ChatClientProtocol): + """Test that function results are appended to existing assistant message when appropriate.""" + + @ai_function(name="test_function") + def test_func(arg1: str) -> str: + return f"Result {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[test_func]) + + # Should have messages with both function call and function result + assert len(response.messages) >= 2 + # Check that we have both a function call and a function result + has_call = any(isinstance(content, FunctionCallContent) for msg in response.messages for content in msg.contents) + has_result = any( + isinstance(content, FunctionResultContent) for msg in response.messages for content in msg.contents + ) + assert has_call + assert has_result + + +async def test_error_recovery_resets_counter(chat_client_base: ChatClientProtocol): + """Test that error counter resets after a successful function call.""" + + call_count = 0 + + @ai_function(name="sometimes_fails") + def sometimes_fails(arg1: str) -> str: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ValueError("First call fails") + return f"Success {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="sometimes_fails", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="2", name="sometimes_fails", arguments='{"arg1": "value2"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[sometimes_fails]) + + # Should have both an error and a success + error_results = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, FunctionResultContent) and content.exception + ] + success_results = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, FunctionResultContent) and content.result + ] + + assert len(error_results) >= 1 + assert len(success_results) >= 1 + assert call_count == 2 # Both calls executed + + +# ==================== STREAMING SCENARIO TESTS ==================== + + +async def test_streaming_approval_request_generated(chat_client_base: ChatClientProtocol): + """Test that approval requests are generated correctly in streaming mode.""" + exec_counter = 0 + + @ai_function(name="test_func", approval_mode="always_require") + def func_with_approval(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Result {arg1}" + + # Setup: function call that requires approval, streamed + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="test_func", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ], + ] + + # Get the streaming response with approval request + updates = [] + async for update in chat_client_base.get_streaming_response( + "hello", tool_choice="auto", tools=[func_with_approval] + ): + updates.append(update) + + # Should have function call update and approval request + approval_requests = [ + content + for update in updates + for content in update.contents + if isinstance(content, FunctionApprovalRequestContent) + ] + assert len(approval_requests) == 1 + assert approval_requests[0].function_call.name == "test_func" + assert exec_counter == 0 # Function not executed yet due to approval requirement + + +async def test_streaming_max_iterations_limit(chat_client_base: ChatClientProtocol): + """Test that MAX_ITERATIONS in streaming mode limits function call loops.""" + exec_counter = 0 + + @ai_function(name="test_function") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + # Set up multiple function call responses to create a loop + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')], + role="assistant", + ), + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')], + role="assistant", + ), + ], + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="2", name="test_function", arguments='{"arg1":')], + role="assistant", + ), + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="2", name="test_function", arguments='"value2"}')], + role="assistant", + ), + ], + # Failsafe response when tool_choice is set to "none" + [ChatResponseUpdate(contents=[TextContent(text="giving up on tools")], role="assistant")], + ] + + # Set max_iterations to 1 in additional_properties + chat_client_base.function_invocation_configuration.max_iterations = 1 + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]): + updates.append(update) + + # With max_iterations=1, we should only execute first function + assert exec_counter == 1 # Only first function executed + # Should have the failsafe message + last_text = "".join(u.text or "" for u in updates if u.text) + assert "I broke out of the function invocation loop..." in last_text + + +async def test_streaming_function_invocation_config_enabled_false(chat_client_base: ChatClientProtocol): + """Test that setting enabled=False disables function invocation in streaming mode.""" + exec_counter = 0 + + @ai_function(name="test_function") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.streaming_responses = [ + [ChatResponseUpdate(contents=[TextContent(text="response without function calling")], role="assistant")], + ] + + # Disable function invocation + chat_client_base.function_invocation_configuration.enabled = False + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]): + updates.append(update) + + # Function should not be executed - when enabled=False, the loop doesn't run + assert exec_counter == 0 + # The response should be from the mock client + assert len(updates) > 0 + + +async def test_streaming_function_invocation_config_max_consecutive_errors(chat_client_base: ChatClientProtocol): + """Test that max_consecutive_errors_per_request limits error retries in streaming mode.""" + + @ai_function(name="error_function") + def error_func(arg1: str) -> str: + raise ValueError("Function error") + + # Set up multiple function call responses that will all error + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="error_function", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ], + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="2", name="error_function", arguments='{"arg1": "value2"}')], + role="assistant", + ), + ], + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="3", name="error_function", arguments='{"arg1": "value3"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="final response")], role="assistant")], + ] + + # Set max_consecutive_errors to 2 + chat_client_base.function_invocation_configuration.max_consecutive_errors_per_request = 2 + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[error_func]): + updates.append(update) + + # Should stop after 2 consecutive errors + error_results = [ + content + for update in updates + for content in update.contents + if isinstance(content, FunctionResultContent) and content.exception + ] + # At least one error occurred + assert len(error_results) >= 1 + # Should have stopped making new function calls after hitting the error limit + function_calls = [ + content for update in updates for content in update.contents if isinstance(content, FunctionCallContent) + ] + # Should have made at most 2 function calls before stopping + assert len(function_calls) <= 2 + + +async def test_streaming_function_invocation_config_terminate_on_unknown_calls_false( + chat_client_base: ChatClientProtocol, +): + """Test that terminate_on_unknown_calls=False returns error message for unknown functions in streaming mode.""" + exec_counter = 0 + + @ai_function(name="known_function") + def known_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="unknown_function", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")], + ] + + # Set terminate_on_unknown_calls to False (default) + chat_client_base.function_invocation_configuration.terminate_on_unknown_calls = False + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[known_func]): + updates.append(update) + + # Should have a result message indicating the tool wasn't found + result_contents = [ + content for update in updates for content in update.contents if isinstance(content, FunctionResultContent) + ] + assert len(result_contents) >= 1 + result_str = result_contents[0].result or result_contents[0].exception or "" + assert "not found" in result_str.lower() + assert exec_counter == 0 # Known function not executed + + +async def test_streaming_function_invocation_config_terminate_on_unknown_calls_true( + chat_client_base: ChatClientProtocol, +): + """Test that terminate_on_unknown_calls=True stops execution on unknown functions in streaming mode.""" + exec_counter = 0 + + @ai_function(name="known_function") + def known_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="unknown_function", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ], + ] + + # Set terminate_on_unknown_calls to True + chat_client_base.function_invocation_configuration.terminate_on_unknown_calls = True + + # Should raise an exception when encountering an unknown function + with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'): + async for _ in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[known_func]): + pass + + assert exec_counter == 0 + + +async def test_streaming_function_invocation_config_include_detailed_errors_true(chat_client_base: ChatClientProtocol): + """Test that include_detailed_errors=True returns detailed error information in streaming mode.""" + + @ai_function(name="error_function") + def error_func(arg1: str) -> str: + raise ValueError("Specific error message that should appear") + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="error_function", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")], + ] + + # Set include_detailed_errors to True + chat_client_base.function_invocation_configuration.include_detailed_errors = True + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[error_func]): + updates.append(update) + + # Should have detailed error message + error_result = next( + content for update in updates for content in update.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Specific error message that should appear" in error_result.result + assert "Exception:" in error_result.result + + +async def test_streaming_function_invocation_config_include_detailed_errors_false( + chat_client_base: ChatClientProtocol, +): + """Test that include_detailed_errors=False returns generic error messages in streaming mode.""" + + @ai_function(name="error_function") + def error_func(arg1: str) -> str: + raise ValueError("Specific error message that should not appear") + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="error_function", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")], + ] + + # Set include_detailed_errors to False (default) + chat_client_base.function_invocation_configuration.include_detailed_errors = False + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[error_func]): + updates.append(update) + + # Should have a generic error message + error_result = next( + content for update in updates for content in update.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Specific error message" not in error_result.result + assert "Error:" in error_result.result # Generic error prefix + + +async def test_streaming_argument_validation_error_with_detailed_errors(chat_client_base: ChatClientProtocol): + """Test that argument validation errors include details when include_detailed_errors=True in streaming mode.""" + + @ai_function(name="typed_function") + def typed_func(arg1: int) -> str: # Expects int, not str + return f"Got {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="typed_function", arguments='{"arg1": "not_an_int"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")], + ] + + # Set include_detailed_errors to True + chat_client_base.function_invocation_configuration.include_detailed_errors = True + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[typed_func]): + updates.append(update) + + # Should have detailed validation error + error_result = next( + content for update in updates for content in update.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Argument parsing failed" in error_result.result + assert "Exception:" in error_result.result # Detailed error included + + +async def test_streaming_argument_validation_error_without_detailed_errors(chat_client_base: ChatClientProtocol): + """Test that argument validation errors are generic when include_detailed_errors=False in streaming mode.""" + + @ai_function(name="typed_function") + def typed_func(arg1: int) -> str: # Expects int, not str + return f"Got {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="typed_function", arguments='{"arg1": "not_an_int"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")], + ] + + # Set include_detailed_errors to False (default) + chat_client_base.function_invocation_configuration.include_detailed_errors = False + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[typed_func]): + updates.append(update) + + # Should have generic validation error + error_result = next( + content for update in updates for content in update.contents if isinstance(content, FunctionResultContent) + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "Argument parsing failed" in error_result.result + assert "Exception:" not in error_result.result # No detailed error + + +async def test_streaming_multiple_function_calls_parallel_execution(chat_client_base: ChatClientProtocol): + """Test that multiple function calls are executed in parallel in streaming mode.""" + import asyncio + + exec_order = [] + + @ai_function(name="func1") + async def func1(arg1: str) -> str: + exec_order.append("func1_start") + await asyncio.sleep(0.01) # Small delay + exec_order.append("func1_end") + return f"Result1 {arg1}" + + @ai_function(name="func2") + async def func2(arg1: str) -> str: + exec_order.append("func2_start") + await asyncio.sleep(0.01) # Small delay + exec_order.append("func2_end") + return f"Result2 {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="func1", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="2", name="func2", arguments='{"arg1": "value2"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")], + ] + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[func1, func2]): + updates.append(update) + + # Both functions should have been executed + assert "func1_start" in exec_order + assert "func1_end" in exec_order + assert "func2_start" in exec_order + assert "func2_end" in exec_order + + # Should have results for both + results = [ + content for update in updates for content in update.contents if isinstance(content, FunctionResultContent) + ] + assert len(results) == 2 + + +async def test_streaming_approval_requests_in_assistant_message(chat_client_base: ChatClientProtocol): + """Approval requests should be added to assistant updates in streaming mode.""" + exec_counter = 0 + + @ai_function(name="test_func", approval_mode="always_require") + def func_with_approval(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Result {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + FunctionCallContent(call_id="1", name="test_func", arguments='{"arg1": "value1"}'), + ], + role="assistant", + ), + ], + ] + + updates = [] + async for update in chat_client_base.get_streaming_response( + "hello", tool_choice="auto", tools=[func_with_approval] + ): + updates.append(update) + + # Should have updates containing both the call and approval request + approval_requests = [ + content + for update in updates + for content in update.contents + if isinstance(content, FunctionApprovalRequestContent) + ] + assert len(approval_requests) == 1 + assert exec_counter == 0 + + +async def test_streaming_error_recovery_resets_counter(chat_client_base: ChatClientProtocol): + """Test that error counter resets after a successful function call in streaming mode.""" + + call_count = 0 + + @ai_function(name="sometimes_fails") + def sometimes_fails(arg1: str) -> str: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ValueError("First call fails") + return f"Success {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="sometimes_fails", arguments='{"arg1": "value1"}')], + role="assistant", + ), + ], + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="2", name="sometimes_fails", arguments='{"arg1": "value2"}')], + role="assistant", + ), + ], + [ChatResponseUpdate(contents=[TextContent(text="done")], role="assistant")], + ] + + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[sometimes_fails]): + updates.append(update) + + # Should have both an error and a success + error_results = [ + content + for update in updates + for content in update.contents + if isinstance(content, FunctionResultContent) and content.exception + ] + success_results = [ + content + for update in updates + for content in update.contents + if isinstance(content, FunctionResultContent) and content.result + ] + + assert len(error_results) >= 1 + assert len(success_results) >= 1 + assert call_count == 2 # Both calls executed diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index e2cf6b8d3d..acd9157363 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -63,6 +63,26 @@ def test_ai_function_decorator_without_args(): assert test_tool(1, 2) == 3 +def test_ai_function_without_args(): + """Test the ai_function decorator.""" + + @ai_function + def test_tool() -> int: + """A simple function that adds two numbers.""" + return 1 + 2 + + assert isinstance(test_tool, ToolProtocol) + assert isinstance(test_tool, AIFunction) + assert test_tool.name == "test_tool" + assert test_tool.description == "A simple function that adds two numbers." + assert test_tool.parameters() == { + "properties": {}, + "title": "test_tool_input", + "type": "object", + } + assert test_tool() == 3 + + async def test_ai_function_decorator_with_async(): """Test the ai_function decorator with an async function.""" diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index d159091311..8af3ed61aa 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -689,12 +689,15 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str] # Test with exception (no result) test_exception = ValueError("Test error message") message_with_exception = ChatMessage( - role="tool", contents=[FunctionResultContent(call_id="call-123", exception=test_exception)] + role="tool", + contents=[ + FunctionResultContent(call_id="call-123", result="Error: Function failed.", exception=test_exception) + ], ) openai_messages = client._openai_chat_message_parser(message_with_exception) assert len(openai_messages) == 1 - assert openai_messages[0]["content"] == "Error: Test error message" + assert openai_messages[0]["content"] == "Error: Function failed." assert openai_messages[0]["tool_call_id"] == "call-123" diff --git a/python/samples/README.md b/python/samples/README.md index f8602b3385..f70a390892 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -218,9 +218,14 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | File | Description | |------|-------------| -| [`getting_started/tools/ai_tool_with_approval.py`](./getting_started/tools/ai_tool_with_approval.py) | Demonstration of a tool with approvals | -| [`getting_started/tools/ai_tool_with_approval_and_threads.py`](./getting_started/tools/ai_tool_with_approval_and_threads.py) | Tool Approvals with Threads | -| [`getting_started/tools/failing_tools.py`](./getting_started/tools/failing_tools.py) | Tool exceptions handled by returning the error for the agent to recover from | +| [`getting_started/tools/ai_function_declaration_only.py`](./getting_started/tools/ai_function_declaration_only.py) | Function declarations without implementations for testing agent reasoning | +| [`getting_started/tools/ai_function_from_dict_with_dependency_injection.py`](./getting_started/tools/ai_function_from_dict_with_dependency_injection.py) | Creating AI functions from dictionary definitions using dependency injection | +| [`getting_started/tools/ai_function_recover_from_failures.py`](./getting_started/tools/ai_function_recover_from_failures.py) | Graceful error handling when tools raise exceptions | +| [`getting_started/tools/ai_function_with_approval.py`](./getting_started/tools/ai_function_with_approval.py) | User approval workflows for function calls without threads | +| [`getting_started/tools/ai_function_with_approval_and_threads.py`](./getting_started/tools/ai_function_with_approval_and_threads.py) | Tool approval workflows using threads for conversation history management | +| [`getting_started/tools/ai_function_with_max_exceptions.py`](./getting_started/tools/ai_function_with_max_exceptions.py) | Limiting tool failure exceptions using max_invocation_exceptions | +| [`getting_started/tools/ai_function_with_max_invocations.py`](./getting_started/tools/ai_function_with_max_invocations.py) | Limiting total tool invocations using max_invocations | +| [`getting_started/tools/ai_functions_in_class.py`](./getting_started/tools/ai_functions_in_class.py) | Using ai_function decorator with class methods for stateful tools | ## Workflows diff --git a/python/samples/getting_started/tools/README.md b/python/samples/getting_started/tools/README.md index e69de29bb2..66ca227da6 100644 --- a/python/samples/getting_started/tools/README.md +++ b/python/samples/getting_started/tools/README.md @@ -0,0 +1,119 @@ +# Tools Examples + +This folder contains examples demonstrating how to use AI functions (tools) with the Agent Framework. AI functions allow agents to interact with external systems, perform computations, and execute custom logic. + +## Examples + +| File | Description | +|------|-------------| +| [`ai_function_declaration_only.py`](ai_function_declaration_only.py) | Demonstrates how to create function declarations without implementations. Useful for testing agent reasoning about tool usage or when tools are defined elsewhere. Shows how agents request tool calls even when the tool won't be executed. | +| [`ai_function_from_dict_with_dependency_injection.py`](ai_function_from_dict_with_dependency_injection.py) | Shows how to create AI functions from dictionary definitions using dependency injection. The function implementation is injected at runtime during deserialization, enabling dynamic tool creation and configuration. Note: This serialization/deserialization feature is in active development. | +| [`ai_function_recover_from_failures.py`](ai_function_recover_from_failures.py) | Demonstrates graceful error handling when tools raise exceptions. Shows how agents receive error information and can recover from failures, deciding whether to retry or respond differently based on the exception. | +| [`ai_function_with_approval.py`](ai_function_with_approval.py) | Shows how to implement user approval workflows for function calls without using threads. Demonstrates both streaming and non-streaming approval patterns where users can approve or reject function executions before they run. | +| [`ai_function_with_approval_and_threads.py`](ai_function_with_approval_and_threads.py) | Demonstrates tool approval workflows using threads for automatic conversation history management. Shows how threads simplify approval workflows by automatically storing and retrieving conversation context. Includes both approval and rejection examples. | +| [`ai_function_with_max_exceptions.py`](ai_function_with_max_exceptions.py) | Shows how to limit the number of times a tool can fail with exceptions using `max_invocation_exceptions`. Useful for preventing expensive tools from being called repeatedly when they keep failing. | +| [`ai_function_with_max_invocations.py`](ai_function_with_max_invocations.py) | Demonstrates limiting the total number of times a tool can be invoked using `max_invocations`. Useful for rate-limiting expensive operations or ensuring tools are only called a specific number of times per conversation. | +| [`ai_functions_in_class.py`](ai_functions_in_class.py) | Shows how to use `ai_function` decorator with class methods to create stateful tools. Demonstrates how class state can control tool behavior dynamically, allowing you to adjust tool functionality at runtime by modifying class properties. | + +## Key Concepts + +### AI Function Features + +- **Function Declarations**: Define tool schemas without implementations for testing or external tools +- **Dependency Injection**: Create tools from configurations with runtime-injected implementations +- **Error Handling**: Gracefully handle and recover from tool execution failures +- **Approval Workflows**: Require user approval before executing sensitive or important operations +- **Invocation Limits**: Control how many times tools can be called or fail +- **Stateful Tools**: Use class methods as tools to maintain state and dynamically control behavior + +### Common Patterns + +#### Basic Tool Definition + +```python +from agent_framework import ai_function +from typing import Annotated + +@ai_function +def my_tool(param: Annotated[str, "Description"]) -> str: + """Tool description for the AI.""" + return f"Result: {param}" +``` + +#### Tool with Approval + +```python +@ai_function(approval_mode="always_require") +def sensitive_operation(data: Annotated[str, "Data to process"]) -> str: + """This requires user approval before execution.""" + return f"Processed: {data}" +``` + +#### Tool with Invocation Limits + +```python +@ai_function(max_invocations=3) +def limited_tool() -> str: + """Can only be called 3 times total.""" + return "Result" + +@ai_function(max_invocation_exceptions=2) +def fragile_tool() -> str: + """Can only fail 2 times before being disabled.""" + return "Result" +``` + +#### Stateful Tools with Classes + +```python +class MyTools: + def __init__(self, mode: str = "normal"): + self.mode = mode + + def process(self, data: Annotated[str, "Data to process"]) -> str: + """Process data based on current mode.""" + if self.mode == "safe": + return f"Safely processed: {data}" + return f"Processed: {data}" + +# Create instance and use methods as tools +tools = MyTools(mode="safe") +agent = client.create_agent(tools=tools.process) + +# Change behavior dynamically +tools.mode = "normal" +``` + +### Error Handling + +When tools raise exceptions: +1. The exception is captured and sent to the agent as a function result +2. The agent receives the error message and can reason about what went wrong +3. The agent can retry with different parameters, use alternative tools, or explain the issue to the user +4. With invocation limits, tools can be disabled after repeated failures + +### Approval Workflows + +Two approaches for handling approvals: + +1. **Without Threads**: Manually manage conversation context, including the query, approval request, and response in each iteration +2. **With Threads**: Thread automatically manages conversation history, simplifying the approval workflow + +## Usage Tips + +- Use **declaration-only** functions when you want to test agent reasoning without execution +- Use **dependency injection** for dynamic tool configuration and plugin architectures +- Implement **approval workflows** for operations that modify data, spend money, or require human oversight +- Set **invocation limits** to prevent runaway costs or infinite loops with expensive tools +- Handle **exceptions gracefully** to create robust agents that can recover from failures +- Use **class-based tools** when you need to maintain state or dynamically adjust tool behavior at runtime + +## Running the Examples + +Each example is a standalone Python script that can be run directly: + +```bash +uv run python ai_function_with_approval.py +``` + +Make sure you have the necessary environment variables configured (like `OPENAI_API_KEY` or Azure credentials) before running the examples. diff --git a/python/samples/getting_started/tools/ai_function_declaration_only.py b/python/samples/getting_started/tools/ai_function_declaration_only.py new file mode 100644 index 0000000000..03a2e8f8ed --- /dev/null +++ b/python/samples/getting_started/tools/ai_function_declaration_only.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework import AIFunction +from agent_framework.openai import OpenAIResponsesClient + +""" +Example of how to create a function that only consists of a declaration without an implementation. +This is useful when you want the agent to use tools that are defined elsewhere or when you want +to test the agent's ability to reason about tool usage without executing them. + +The only difference is that you provide an AIFunction without a function. +If you need a input_model, you can still provide that as well. +""" + + +async def main(): + function_declaration = AIFunction[None, None]( + name="get_current_time", + description="Get the current time in ISO 8601 format.", + ) + + agent = OpenAIResponsesClient().create_agent( + name="DeclarationOnlyToolAgent", + instructions="You are a helpful agent that uses tools.", + tools=function_declaration, + ) + query = "What is the current time?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result.to_json(indent=2)}\n") + + +""" +Expected result: +User: What is the current time? +Result: { + "type": "agent_run_response", + "messages": [ + { + "type": "chat_message", + "role": { + "type": "role", + "value": "assistant" + }, + "contents": [ + { + "type": "function_call", + "call_id": "call_0flN9rfGLK8LhORy4uMDiRSC", + "name": "get_current_time", + "arguments": "{}", + "fc_id": "fc_0fd5f269955c589f016904c46584348195b84a8736e61248de" + } + ], + "author_name": "DeclarationOnlyToolAgent", + "additional_properties": {} + } + ], + "response_id": "resp_0fd5f269955c589f016904c462d5cc819599d28384ba067edc", + "created_at": "2025-10-31T15:14:58.000000Z", + "usage_details": { + "type": "usage_details", + "input_token_count": 63, + "output_token_count": 145, + "total_token_count": 208, + "openai.reasoning_tokens": 128 + }, + "additional_properties": {} +} +""" + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/python/samples/getting_started/tools/tool_with_injected_func.py b/python/samples/getting_started/tools/ai_function_from_dict_with_dependency_injection.py similarity index 100% rename from python/samples/getting_started/tools/tool_with_injected_func.py rename to python/samples/getting_started/tools/ai_function_from_dict_with_dependency_injection.py diff --git a/python/samples/getting_started/tools/failing_tools.py b/python/samples/getting_started/tools/ai_function_recover_from_failures.py similarity index 100% rename from python/samples/getting_started/tools/failing_tools.py rename to python/samples/getting_started/tools/ai_function_recover_from_failures.py diff --git a/python/samples/getting_started/tools/ai_tool_with_approval.py b/python/samples/getting_started/tools/ai_function_with_approval.py similarity index 100% rename from python/samples/getting_started/tools/ai_tool_with_approval.py rename to python/samples/getting_started/tools/ai_function_with_approval.py diff --git a/python/samples/getting_started/tools/ai_tool_with_approval_and_threads.py b/python/samples/getting_started/tools/ai_function_with_approval_and_threads.py similarity index 100% rename from python/samples/getting_started/tools/ai_tool_with_approval_and_threads.py rename to python/samples/getting_started/tools/ai_function_with_approval_and_threads.py diff --git a/python/samples/getting_started/tools/ai_function_with_max_exceptions.py b/python/samples/getting_started/tools/ai_function_with_max_exceptions.py new file mode 100644 index 0000000000..b1600b7299 --- /dev/null +++ b/python/samples/getting_started/tools/ai_function_with_max_exceptions.py @@ -0,0 +1,188 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import FunctionCallContent, FunctionResultContent, ai_function +from agent_framework.openai import OpenAIResponsesClient + +""" +Some tools are very expensive to run, so you may want to limit the number of times +it tries to call them and fails. This sample shows a tool that can only raise exceptions a +limited number of times. +""" + + +# we trick the AI into calling this function with 0 as denominator to trigger the exception +@ai_function(max_invocation_exceptions=1) +def safe_divide( + a: Annotated[int, "Numerator"], + b: Annotated[int, "Denominator"], +) -> str: + """Divide two numbers can be used with 0 as denominator.""" + try: + result = a / b # Will raise ZeroDivisionError + except ZeroDivisionError as exc: + print(f" Tool failed with error: {exc}") + raise + + return f"{a} / {b} = {result}" + + +async def main(): + # tools = Tools() + agent = OpenAIResponsesClient().create_agent( + name="ToolAgent", + instructions="Use the provided tools.", + tools=[safe_divide], + ) + thread = agent.get_new_thread() + print("=" * 60) + print("Step 1: Call divide(10, 0) - tool raises exception") + response = await agent.run("Divide 10 by 0", thread=thread) + print(f"Response: {response.text}") + print("=" * 60) + print("Step 2: Call divide(100, 0) - will refuse to execute due to max_invocation_exceptions") + response = await agent.run("Divide 100 by 0", thread=thread) + print(f"Response: {response.text}") + print("=" * 60) + print(f"Number of tool calls attempted: {safe_divide.invocation_count}") + print(f"Number of tool calls failed: {safe_divide.invocation_exception_count}") + print("Replay the conversation:") + assert thread.message_store + assert thread.message_store.list_messages + for idx, msg in enumerate(await thread.message_store.list_messages()): + if msg.text: + print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") + for content in msg.contents: + if isinstance(content, FunctionCallContent): + print( + f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" + ) + if isinstance(content, FunctionResultContent): + print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") + + +""" +Expected Output: +============================================================ +Step 1: Call divide(10, 0) - tool raises exception + Tool failed with error: division by zero +[2025-10-31 15:39:53 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR] +Function failed. Error: division by zero +Response: Division by zero is undefined in standard arithmetic. There is no finite value for 10 ÷ 0. + +If you want alternatives: +- A valid example: 10 ÷ 2 = 5. +- To handle safely in code, you can check the denominator first (e.g., in Python: if b == 0: + handle error else: compute a/b). +- If you’re curious about limits: as x → 0+, 10/x → +∞; as x → 0−, 10/x → −∞; there is no finite limit. + +Would you like me to show a safe division snippet in a specific language, or compute something else? +============================================================ +Step 2: Call divide(100, 0) - will refuse to execute due to max_invocations +[2025-10-31 15:40:09 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR] +Function failed. Error: Function 'safe_divide' has reached its maximum exception limit, you tried to use this +tool too many times and it kept failing. +Response: Division by zero is undefined in standard arithmetic, so 100 ÷ 0 has no finite value. + +If you’re coding and want safe handling, here are quick patterns in a few languages: + +- Python + def safe_divide(a, b): + if b == 0: + return None # or raise an exception + return a / b + + safe_divide(100, 0) # -> None + +- JavaScript + function safeDivide(a, b) { + if (b === 0) return undefined; // or throw + return a / b; + } + + safeDivide(100, 0) // -> undefined + +- Java + public static Double safeDivide(double a, double b) { + if (b == 0.0) throw new ArithmeticException("Divide by zero"); + return a / b; + } + + safeDivide(100, 0) // -> exception + +- C/C++ + double safeDivide(double a, double b) { + if (b == 0.0) return std::numeric_limits::infinity(); // or handle error + return a / b; + } + +Note: In many languages, dividing by zero with floating-point numbers yields Infinity (or -Infinity) or NaN, +but integer division typically raises an error. + +Would you like a snippet in a specific language or to see a math explanation (limits) for what happens as the +divisor approaches zero? +============================================================ +Number of tool calls attempted: 1 +Number of tool calls failed: 1 +Replay the conversation: +1 user: Divide 10 by 0 +2 ToolAgent: calling function: safe_divide with arguments: {"a":10,"b":0} +3 tool: division by zero +4 ToolAgent: Division by zero is undefined in standard arithmetic. There is no finite value for 10 ÷ 0. + +If you want alternatives: +- A valid example: 10 ÷ 2 = 5. +- To handle safely in code, you can check the denominator first (e.g., in Python: if b == 0: + handle error else: compute a/b). +- If you’re curious about limits: as x → 0+, 10/x → +∞; as x → 0−, 10/x → −∞; there is no finite limit. + +Would you like me to show a safe division snippet in a specific language, or compute something else? +5 user: Divide 100 by 0 +6 ToolAgent: calling function: safe_divide with arguments: {"a":100,"b":0} +7 tool: Function 'safe_divide' has reached its maximum exception limit, you tried to use this tool too many times + and it kept failing. +8 ToolAgent: Division by zero is undefined in standard arithmetic, so 100 ÷ 0 has no finite value. + +If you’re coding and want safe handling, here are quick patterns in a few languages: + +- Python + def safe_divide(a, b): + if b == 0: + return None # or raise an exception + return a / b + + safe_divide(100, 0) # -> None + +- JavaScript + function safeDivide(a, b) { + if (b === 0) return undefined; // or throw + return a / b; + } + + safeDivide(100, 0) // -> undefined + +- Java + public static Double safeDivide(double a, double b) { + if (b == 0.0) throw new ArithmeticException("Divide by zero"); + return a / b; + } + + safeDivide(100, 0) // -> exception + +- C/C++ + double safeDivide(double a, double b) { + if (b == 0.0) return std::numeric_limits::infinity(); // or handle error + return a / b; + } + +Note: In many languages, dividing by zero with floating-point numbers yields Infinity (or -Infinity) or NaN, +but integer division typically raises an error. + +Would you like a snippet in a specific language or to see a math explanation (limits) for what happens as the +divisor approaches zero? +""" + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/tools/ai_function_with_max_invocations.py b/python/samples/getting_started/tools/ai_function_with_max_invocations.py new file mode 100644 index 0000000000..6a52e91329 --- /dev/null +++ b/python/samples/getting_started/tools/ai_function_with_max_invocations.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import FunctionCallContent, FunctionResultContent, ai_function +from agent_framework.openai import OpenAIResponsesClient + +""" +For tools you can specify if there is a maximum number of invocations allowed. +This sample shows a tool that can only be invoked once. +""" + + +@ai_function(max_invocations=1) +def unicorn_function(times: Annotated[int, "The number of unicorns to return."]) -> str: + """This function returns precious unicorns!""" + return f"{'🦄' * times}✨" + + +async def main(): + # tools = Tools() + agent = OpenAIResponsesClient().create_agent( + name="ToolAgent", + instructions="Use the provided tools.", + tools=[unicorn_function], + ) + thread = agent.get_new_thread() + print("=" * 60) + print("Step 1: Call unicorn_function") + response = await agent.run("Call 5 unicorns!", thread=thread) + print(f"Response: {response.text}") + print("=" * 60) + print("Step 2: Call unicorn_function again - will refuse to execute due to max_invocations") + response = await agent.run("Call 10 unicorns and use the function to do it.", thread=thread) + print(f"Response: {response.text}") + print("=" * 60) + print(f"Number of tool calls attempted: {unicorn_function.invocation_count}") + print(f"Number of tool calls failed: {unicorn_function.invocation_exception_count}") + print("Replay the conversation:") + assert thread.message_store + assert thread.message_store.list_messages + for idx, msg in enumerate(await thread.message_store.list_messages()): + if msg.text: + print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") + for content in msg.contents: + if isinstance(content, FunctionCallContent): + print( + f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" + ) + if isinstance(content, FunctionResultContent): + print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") + + +""" +Expected Output: +============================================================ +Step 1: Call unicorn_function +Response: Five unicorns summoned: 🦄🦄🦄🦄🦄✨ +============================================================ +Step 2: Call unicorn_function again - will refuse to execute due to max_invocations +[2025-10-31 15:54:40 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR] +Function failed. Error: Function 'unicorn_function' has reached its maximum invocation limit, +you can no longer use this tool. +Response: The unicorn function has reached its maximum invocation limit. I can’t call it again right now. + +Here are 10 unicorns manually: 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 + +Would you like me to try again later, or generate something else? +============================================================ +Number of tool calls attempted: 1 +Number of tool calls failed: 0 +Replay the conversation: +1 user: Call 5 unicorns! +2 ToolAgent: calling function: unicorn_function with arguments: {"times":5} +3 tool: 🦄🦄🦄🦄🦄✨ +4 ToolAgent: Five unicorns summoned: 🦄🦄🦄🦄🦄✨ +5 user: Call 10 unicorns and use the function to do it. +6 ToolAgent: calling function: unicorn_function with arguments: {"times":10} +7 tool: Function 'unicorn_function' has reached its maximum invocation limit, you can no longer use this tool. +8 ToolAgent: The unicorn function has reached its maximum invocation limit. I can’t call it again right now. + +Here are 10 unicorns manually: 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 🦄 + +Would you like me to try again later, or generate something else? +""" + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/tools/ai_functions_in_class.py b/python/samples/getting_started/tools/ai_functions_in_class.py new file mode 100644 index 0000000000..995383cc70 --- /dev/null +++ b/python/samples/getting_started/tools/ai_functions_in_class.py @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import ai_function +from agent_framework.openai import OpenAIResponsesClient + +""" +This sample demonstrates using ai_function within a class, +showing how to manage state within the class that affects tool behavior. + +And how to use ai_function-decorated methods as tools in an agent in order to adjust the behavior of a tool. +""" + + +class MyFunctionClass: + def __init__(self, safe: bool = False) -> None: + """Simple class with two ai_functions: divide and add. + + The safe parameter controls whether divide raises on division by zero or returns `infinity` for divide by zero. + """ + self.safe = safe + + def divide( + self, + a: Annotated[int, "Numerator"], + b: Annotated[int, "Denominator"], + ) -> str: + """Divide two numbers, safe to use also with 0 as denominator.""" + result = "∞" if b == 0 and self.safe else a / b + return f"{a} / {b} = {result}" + + def add( + self, + x: Annotated[int, "First number"], + y: Annotated[int, "Second number"], + ) -> str: + return f"{x} + {y} = {x + y}" + + +async def main(): + # Creating my function class with safe division enabled + tools = MyFunctionClass(safe=True) + # Applying the ai_function decorator to one of the methods of the class + add_function = ai_function(description="Add two numbers.")(tools.add) + + agent = OpenAIResponsesClient().create_agent( + name="ToolAgent", + instructions="Use the provided tools.", + ) + print("=" * 60) + print("Step 1: Call divide(10, 0) - tool returns infinity") + query = "Divide 10 by 0" + response = await agent.run( + query, + tools=[add_function, tools.divide], + ) + print(f"Response: {response.text}") + print("=" * 60) + print("Step 2: Call set safe to False and call again") + # Disabling safe mode to allow exceptions + tools.safe = False + response = await agent.run(query, tools=[add_function, tools.divide]) + print(f"Response: {response.text}") + print("=" * 60) + + +""" +Expected Output: +============================================================ +Step 1: Call divide(10, 0) - tool returns infinity +Response: Division by zero is undefined in standard arithmetic. There is no real number that equals 10 divided by 0. + +- If you look at limits: as x → 0+ (denominator approaches 0 from the positive side), 10/x → +∞; as x → 0−, 10/x → −∞. +- Some calculators may display "infinity" or give an error, but that's not a real number. + +If you want a numeric surrogate, you can use a small nonzero denominator, e.g., 10/0.001 = 10000. Would you like to +see more on limits or handle it with a tiny epsilon? +============================================================ +Step 2: Call set safe to False and call again +[2025-10-31 16:17:44 - /Users/edvan/Work/agent-framework/python/packages/core/agent_framework/_tools.py:718 - ERROR] +Function failed. Error: division by zero +Response: Division by zero is undefined in standard arithmetic. There is no number y such that 0 × y = 10. + +If you’re looking at limits: +- as x → 0+, 10/x → +∞ +- as x → 0−, 10/x → −∞ +So the limit does not exist. + +In programming, dividing by zero usually raises an error or results in special values (e.g., NaN or ∞) depending +on the language. + +If you want, tell me what you’d like to do instead (e.g., compute 10 divided by 2, or handle division by zero safely +in code), and I can help with examples. +============================================================ +""" + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/tools/function_invocation_configuration.py b/python/samples/getting_started/tools/function_invocation_configuration.py new file mode 100644 index 0000000000..bb0e6b0798 --- /dev/null +++ b/python/samples/getting_started/tools/function_invocation_configuration.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework.openai import OpenAIResponsesClient + +""" +This sample demonstrates how to configure function invocation settings +for an client and use a simple ai_function as a tool in an agent. + +This behavior is the same for all chat client types. +""" + + +def add( + x: Annotated[int, "First number"], + y: Annotated[int, "Second number"], +) -> str: + return f"{x} + {y} = {x + y}" + + +async def main(): + client = OpenAIResponsesClient() + if client.function_invocation_configuration is not None: + client.function_invocation_configuration.include_detailed_errors = True + client.function_invocation_configuration.max_iterations = 40 + print(f"Function invocation configured as: \n{client.function_invocation_configuration.to_json(indent=2)}") + + agent = client.create_agent(name="ToolAgent", instructions="Use the provided tools.", tools=add) + + print("=" * 60) + print("Call add(239847293, 29834)") + query = "Add 239847293 and 29834" + response = await agent.run(query) + print(f"Response: {response.text}") + + +""" +Expected Output: +============================================================ +Function invocation configured as: +{ + "type": "function_invocation_configuration", + "enabled": true, + "max_iterations": 40, + "max_consecutive_errors_per_request": 3, + "terminate_on_unknown_calls": false, + "additional_tools": [], + "include_detailed_errors": true +} +============================================================ +Call add(239847293, 29834) +Response: 239,877,127 +""" + +if __name__ == "__main__": + asyncio.run(main()) From 54db13c22fdb79fcf4f78d48a41ebe4a20e82a7d Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 5 Nov 2025 10:42:39 +0100 Subject: [PATCH 12/16] Python: add support for Python 3.14 (#1904) * add tests for py3.14 and add classifier * remove macos * allow openai v2 --- .github/workflows/python-code-quality.yml | 2 +- .github/workflows/python-lab-tests.yml | 2 +- .github/workflows/python-tests.yml | 2 +- python/packages/a2a/pyproject.toml | 1 + python/packages/anthropic/pyproject.toml | 1 + python/packages/azure-ai/pyproject.toml | 1 + python/packages/copilotstudio/pyproject.toml | 1 + python/packages/core/pyproject.toml | 3 ++- python/packages/devui/pyproject.toml | 1 + python/packages/lab/pyproject.toml | 3 ++- python/packages/mem0/pyproject.toml | 1 + python/packages/purview/pyproject.toml | 1 + python/packages/redis/pyproject.toml | 1 + python/pyproject.toml | 1 + python/uv.lock | 2 +- 15 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-code-quality.yml b/.github/workflows/python-code-quality.yml index a39445c643..871436509c 100644 --- a/.github/workflows/python-code-quality.yml +++ b/.github/workflows/python-code-quality.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10"] + python-version: ["3.10", "3.14"] runs-on: ubuntu-latest continue-on-error: true defaults: diff --git a/.github/workflows/python-lab-tests.yml b/.github/workflows/python-lab-tests.yml index bae78be27c..ae526cf962 100644 --- a/.github/workflows/python-lab-tests.yml +++ b/.github/workflows/python-lab-tests.yml @@ -48,7 +48,7 @@ jobs: strategy: fail-fast: true matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] # TODO(ekzhu): re-enable macos-latest when this is fixed: https://github.com/actions/runner-images/issues/11881 os: [ubuntu-latest, windows-latest] env: diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 7a6badaba4..697a8ff4a7 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: true matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] # todo: add macos-latest when problems are resolved os: [ubuntu-latest, windows-latest] env: diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 058a843523..c7a153c9fc 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 65f3419276..e31e93ea7a 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index e68fdb0b66..8119e7b47c 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 7d2b927201..3ad6aad137 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index c4cef634a3..77dae0ea63 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ @@ -32,7 +33,7 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-grpc>=1.36.0", "opentelemetry-semantic-conventions-ai>=0.4.13", # connectors and functions - "openai>=1.99.0,<2", + "openai>=1.99.0", "azure-identity>=1,<2", "mcp[ws]>=1.13", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 02d1e7de0a..2e84110e5d 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index d0a91e4147..05abc5b6c1 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "agent-framework-core", @@ -118,7 +119,7 @@ ignore = [ "INP001", # Ignore missing __init__.py in namespace packages. "RUF029", # Allow use of 'assert' statements; assertions are used for internal checks in experimental code. "ASYNC240", # Allow 'async for' outside of async functions in test and experimental code. -] +] [tool.coverage.run] omit = [ diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index a59acf5e86..a02c266ae2 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index e7f120a23a..905793ebe9 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Framework :: Pydantic :: 2", "Typing :: Typed", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 3e359b522e..a5653d59f1 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/pyproject.toml b/python/pyproject.toml index 72eb4ba258..b798753363 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", ] dependencies = [ diff --git a/python/uv.lock b/python/uv.lock index eb508ff2ed..f8bbf5feb6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -324,7 +324,7 @@ requires-dist = [ { name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" }, { name = "azure-identity", specifier = ">=1,<2" }, { name = "mcp", extras = ["ws"], specifier = ">=1.13" }, - { name = "openai", specifier = ">=1.99.0,<2" }, + { name = "openai", specifier = ">=1.99.0" }, { name = "opentelemetry-api", specifier = ">=1.24" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.36.0" }, { name = "opentelemetry-sdk", specifier = ">=1.24" }, From bb8ef466de7a3ca6b51a83803d6fc91c8d63ed61 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 5 Nov 2025 10:55:26 +0100 Subject: [PATCH 13/16] .NET: Improve fidelity of OpenAI ChatCompletions Hosting (#1785) * rename, support json serialization * wip * non-streaming * streaming? * proper streaming types * comments + fix audio parse * copilot suggestions * proper stopsequences type * build options as i could * annotations * proper generation of Id for chatcompletions * string length as in chatcompletions api ref * image url * support tools * rework API * introduce tests for chatcompletions * function calling / serialization tests / fixes * more tests and coverage * fix format * sort usings * nit * address PR comments * nits --- .../AgentWebChat.AgentHost/Program.cs | 10 +- .../AIAgentChatCompletionsProcessor.cs | 157 ++- .../AgentRunResponseExtensions.cs | 209 ++++ .../ChatCompletionsJsonContext.cs | 63 ++ .../ChatCompletionsJsonSerializerOptions.cs | 24 + .../ChatClientAgentRunOptionsConverter.cs | 118 +++ .../Converters/MessageContentPartConverter.cs | 59 ++ .../ChatCompletions/Models/ChatCompletion.cs | 68 ++ .../Models/ChatCompletionChoice.cs | 216 ++++ .../Models/ChatCompletionChunk.cs | 121 +++ .../Models/ChatCompletionRequestMessage.cs | 175 ++++ .../ChatCompletions/Models/CompletionUsage.cs | 133 +++ .../Models/CreateChatCompletion.cs | 258 +++++ .../ChatCompletions/Models/MessageContent.cs | 167 +++ .../Models/MessageContentPart.cs | 160 +++ .../ChatCompletions/Models/ResponseFormat.cs | 282 +++++ .../ChatCompletions/Models/StopSequences.cs | 193 ++++ .../ChatCompletions/Models/Tool.cs | 164 +++ .../ChatCompletions/Models/ToolChoice.cs | 384 +++++++ .../Utils/ChatCompletionsOptionsExtensions.cs | 52 - ...tRouteBuilderExtensions.ChatCompletions.cs | 82 +- .../HostApplicationBuilderExtensions.cs | 16 +- .../IdGeneratorHelpers.cs | 98 ++ .../Responses/IdGenerator.cs | 96 +- .../ServiceCollectionExtensions.cs | 17 +- .../ConformanceTestBase.cs | 78 +- .../ChatCompletions/basic/request.json | 12 + .../ChatCompletions/basic/response.json | 33 + .../function_calling/request.json | 34 + .../function_calling/response.json | 43 + .../ChatCompletions/json_mode/request.json | 36 + .../ChatCompletions/json_mode/response.json | 33 + .../ChatCompletions/multi_turn/request.json | 18 + .../ChatCompletions/multi_turn/response.json | 33 + .../ChatCompletions/streaming/request.json | 12 + .../ChatCompletions/streaming/response.txt | 21 + .../system_message/request.json | 14 + .../system_message/response.json | 33 + .../ContentTypeEventGeneratorTests.cs | 38 +- ....Agents.AI.Hosting.OpenAI.UnitTests.csproj | 14 +- .../OpenAIChatCompletionsConformanceTests.cs | 495 +++++++++ .../OpenAIChatCompletionsIntegrationTests.cs | 974 ++++++++++++++++++ ...OpenAIChatCompletionsSerializationTests.cs | 576 +++++++++++ .../OpenAIResponsesConformanceTests.cs | 78 +- .../OpenAIResponsesSerializationTests.cs | 94 +- .../StreamingEventConformanceTests.cs | 102 +- 46 files changed, 5687 insertions(+), 406 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 571b07b1d5..d86c53958d 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -20,14 +20,14 @@ builder.Services.AddProblemDetails(); // Configure the chat model and our agent. builder.AddKeyedChatClient("chat-model"); -builder.AddAIAgent( +var pirateAgentBuilder = builder.AddAIAgent( "pirate", instructions: "You are a pirate. Speak like a pirate", description: "An agent that speaks like a pirate.", chatClientServiceKey: "chat-model") .WithInMemoryThreadStore(); -builder.AddAIAgent("knights-and-knaves", (sp, key) => +var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) => { var chatClient = sp.GetRequiredKeyedService("chat-model"); @@ -80,6 +80,8 @@ var literatureAgent = builder.AddAIAgent("literator", builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); + +builder.AddOpenAIChatCompletions(); builder.AddOpenAIResponses(); var app = builder.Build(); @@ -104,8 +106,8 @@ app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", age app.MapOpenAIResponses(); -app.MapOpenAIChatCompletions("pirate"); -app.MapOpenAIChatCompletions("knights-and-knaves"); +app.MapOpenAIChatCompletions(pirateAgentBuilder); +app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder); // Map the agents HTTP endpoints app.MapAgentDiscovery("/agents"); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs index f32fcc8db8..86eb57b7c3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs @@ -1,70 +1,44 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Buffers; -using System.ClientModel.Primitives; +using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Linq; using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using OpenAI.Chat; -using ChatMessage = Microsoft.Extensions.AI.ChatMessage; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; -internal sealed class AIAgentChatCompletionsProcessor +internal static class AIAgentChatCompletionsProcessor { - private readonly AIAgent _agent; - - public AIAgentChatCompletionsProcessor(AIAgent agent) + public static async Task CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken) { - this._agent = agent; - } + ArgumentNullException.ThrowIfNull(agent); - public async Task CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken) - { - AgentThread? agentThread = null; // not supported to resolve from conversationId + var chatMessages = request.Messages.Select(i => i.ToChatMessage()); + var chatClientAgentRunOptions = request.BuildOptions(); - var inputItems = chatCompletionOptions.GetMessages(); - var chatMessages = inputItems.AsChatMessages(); - - if (chatCompletionOptions.GetStream()) + if (request.Stream == true) { - return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages); + return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions); } - var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false); - return new OpenAIChatCompletionResult(agentResponse); + var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + return Results.Ok(response.ToChatCompletion(request)); } - private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult - { - public async Task ExecuteAsync(HttpContext httpContext) - { - // note: OpenAI SDK types provide their own serialization implementation - // so we cant simply return IResult wrap for the typed-object. - // instead writing to the response body can be done. - - var cancellationToken = httpContext.RequestAborted; - var response = httpContext.Response; - - var chatResponse = agentRunResponse.AsChatResponse(); - var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion(); - var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel; - Debug.Assert(openAIChatCompletionJsonModel is not null); - - var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false }); - openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json); - await writer.FlushAsync(cancellationToken).ConfigureAwait(false); - } - } - - private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable chatMessages) : IResult + private sealed class StreamingResponse( + AIAgent agent, + CreateChatCompletion request, + IEnumerable chatMessages, + ChatClientAgentRunOptions? options) : IResult { public Task ExecuteAsync(HttpContext httpContext) { @@ -79,26 +53,99 @@ internal sealed class AIAgentChatCompletionsProcessor httpContext.Features.GetRequiredFeature().DisableBuffering(); return SseFormatter.WriteAsync( - source: this.GetStreamingResponsesAsync(cancellationToken), + source: this.GetStreamingChunksAsync(cancellationToken), destination: response.Body, itemFormatter: (sseItem, bufferWriter) => { - var sseDataJsonModel = (IJsonModel)sseItem.Data; - var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json); - bufferWriter.Write(json); + using var writer = new Utf8JsonWriter(bufferWriter); + JsonSerializer.Serialize(writer, sseItem.Data, ChatCompletionsJsonContext.Default.ChatCompletionChunk); + writer.Flush(); }, cancellationToken); } - private async IAsyncEnumerable> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + private async IAsyncEnumerable> GetStreamingChunksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { - AgentThread? agentThread = null; + // The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp. + DateTimeOffset? createdAt = null; + var chunkId = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13); - var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken); - var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync(); - await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken)) { - yield return new SseItem(streamingChatCompletionUpdate); + var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate) + ? chatResponseUpdate.FinishReason.ToString() + : "stop"; + + var choiceChunks = new List(); + CompletionUsage? usageDetails = null; + + createdAt ??= agentRunResponseUpdate.CreatedAt; + + foreach (var content in agentRunResponseUpdate.Contents) + { + // usage content is handled separately + if (content is UsageContent usageContent && usageContent.Details != null) + { + usageDetails = usageContent.Details.ToCompletionUsage(); + continue; + } + + ChatCompletionDelta? delta = content switch + { + TextContent textContent => new() { Content = textContent.Text }, + + // image + DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() { Content = imageContent.Base64Data.ToString() }, + UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() { Content = urlContent.Uri.ToString() }, + + // audio + DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() { Content = audioContent.Base64Data.ToString() }, + + // file + DataContent fileContent => new() { Content = fileContent.Base64Data.ToString() }, + HostedFileContent fileContent => new() { Content = fileContent.FileId }, + + // function call + FunctionCallContent functionCallContent => new() + { + ToolCalls = [functionCallContent.ToChoiceMessageToolCall()] + }, + + // function result. ChatCompletions dont provide the results of function result per API reference + FunctionResultContent functionResultContent => null, + + // ignore + _ => null + }; + + if (delta is null) + { + // unsupported but expected content type. + continue; + } + + delta.Role = agentRunResponseUpdate.Role?.Value ?? "user"; + + var choiceChunk = new ChatCompletionChoiceChunk + { + Index = 0, + Delta = delta, + FinishReason = finishReason + }; + + choiceChunks.Add(choiceChunk); + } + + var chunk = new ChatCompletionChunk + { + Id = chunkId, + Created = (createdAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(), + Model = request.Model, + Choices = choiceChunks, + Usage = usageDetails + }; + + yield return new(chunk); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs new file mode 100644 index 0000000000..9674b261a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +/// +/// Extension methods for converting agent responses to ChatCompletion models. +/// +internal static class AgentRunResponseExtensions +{ + public static ChatCompletion ToChatCompletion(this AgentRunResponse agentRunResponse, CreateChatCompletion request) + { + IList choices = agentRunResponse.ToChoices(); + + return new ChatCompletion + { + Id = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13), + Choices = choices, + Created = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(), + Model = request.Model, + Usage = agentRunResponse.Usage.ToCompletionUsage(), + ServiceTier = request.ServiceTier ?? "default" + }; + } + + public static List ToChoices(this AgentRunResponse agentRunResponse) + { + var chatCompletionChoices = new List(); + var index = 0; + + var finishReason = (agentRunResponse.RawRepresentation is ChatResponse { FinishReason: not null } chatResponse) + ? chatResponse.FinishReason.ToString() + : "stop"; // "stop" is a natural stop point; returning this by-default + + foreach (var message in agentRunResponse.Messages) + { + foreach (var content in message.Contents) + { + ChoiceMessage? choiceMessage = content switch + { + // text + TextContent textContent => new() + { + Content = textContent.Text + }, + + // image, see how MessageContentPartConverter packs the content types + DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() + { + Content = imageContent.Base64Data.ToString() + }, + UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() + { + Content = urlContent.Uri.ToString() + }, + + // audio + DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() + { + Audio = new() + { + Data = audioContent.Base64Data.ToString(), + Id = audioContent.Name, + //Transcript = , + //ExpiresAt = , + }, + }, + + // file (neither audio nor image) + DataContent fileContent => new() + { + Content = fileContent.Base64Data.ToString() + }, + HostedFileContent fileContent => new() + { + Content = fileContent.FileId + }, + + // function call + FunctionCallContent functionCallContent => new() + { + ToolCalls = [functionCallContent.ToChoiceMessageToolCall()] + }, + + // function result. ChatCompletions dont provide the results of function result per API reference + FunctionResultContent functionResultContent => null, + + // ignore + _ => null + }; + + if (choiceMessage is null) + { + // not supported, but expected content type. + continue; + } + + choiceMessage.Role = message.Role.Value; + choiceMessage.Annotations = content.Annotations?.ToChoiceMessageAnnotations(); + + var choice = new ChatCompletionChoice + { + Index = index++, + Message = choiceMessage, + FinishReason = finishReason + }; + + chatCompletionChoices.Add(choice); + } + } + + return chatCompletionChoices; + } + + /// + /// Converts UsageDetails to CompletionUsage. + /// + /// The usage details to convert. + /// A CompletionUsage object with zeros if usage is null. + public static CompletionUsage ToCompletionUsage(this UsageDetails? usage) + { + if (usage == null) + { + return CompletionUsage.Zero; + } + + var cachedTokens = usage.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cachedInputToken) ?? false + ? (int)cachedInputToken + : 0; + var reasoningTokens = + usage.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoningToken) ?? false + ? (int)reasoningToken + : 0; + + return new CompletionUsage + { + PromptTokens = (int)(usage.InputTokenCount ?? 0), + PromptTokensDetails = new() { CachedTokens = cachedTokens }, + CompletionTokens = (int)(usage.OutputTokenCount ?? 0), + CompletionTokensDetails = new() { ReasoningTokens = reasoningTokens }, + TotalTokens = (int)(usage.TotalTokenCount ?? 0) + }; + } + + public static IList ToChoiceMessageAnnotations(this IList annotations) + { + var result = new List(); + foreach (var annotation in annotations.OfType()) + { + if (annotation is null) + { + continue; + } + + // may point to mulitple regions in the AIContent. + // we need to unroll another loop for regions then -> chatCompletions only point to single region per annotation + + var regions = annotation.AnnotatedRegions?.OfType().Where(x => x.StartIndex is not null && x.EndIndex is not null); + if (regions is not null) + { + foreach (var region in regions) + { + result.Add(new() + { + AnnotationUrlCitation = new AnnotationUrlCitation + { + Url = annotation.Url?.ToString(), + Title = annotation.Title, + StartIndex = region.StartIndex, + EndIndex = region.EndIndex + } + }); + } + } + else + { + result.Add(new() + { + AnnotationUrlCitation = new AnnotationUrlCitation + { + Url = annotation.Url?.ToString(), + Title = annotation.Title + } + }); + } + } + + return result; + } + + public static ChoiceMessageToolCall ToChoiceMessageToolCall(this FunctionCallContent functionCall) + { + return new() + { + Id = functionCall.CallId, + Function = new() + { + Name = functionCall.Name, + Arguments = JsonSerializer.Serialize(functionCall.Arguments, ChatCompletionsJsonContext.Default.DictionaryStringObject) + } + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs new file mode 100644 index 0000000000..25aa47dfb7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + AllowOutOfOrderMetadataProperties = true, + WriteIndented = false)] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(CreateChatCompletion))] +[JsonSerializable(typeof(StopSequences))] +[JsonSerializable(typeof(ChatCompletion))] +[JsonSerializable(typeof(ChatCompletionRequestMessage))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(MessageContent))] +[JsonSerializable(typeof(MessageContentPart))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(TextContentPart))] +[JsonSerializable(typeof(ImageContentPart))] +[JsonSerializable(typeof(AudioContentPart))] +[JsonSerializable(typeof(FileContentPart))] +[JsonSerializable(typeof(ChatCompletionChoice))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(ChoiceMessage))] +[JsonSerializable(typeof(ChoiceMessageAnnotation))] +[JsonSerializable(typeof(ChoiceMessageAudio))] +[JsonSerializable(typeof(ChoiceMessageFunctionCall))] +[JsonSerializable(typeof(ChoiceMessageToolCall))] +[JsonSerializable(typeof(AnnotationUrlCitation))] +[JsonSerializable(typeof(ChatCompletionChoiceChunk))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(ChatCompletionChunk))] +[JsonSerializable(typeof(ChatCompletionDelta))] +[JsonSerializable(typeof(ToolChoice))] +[JsonSerializable(typeof(AllowedToolsChoice))] +[JsonSerializable(typeof(AllowedToolsConfiguration))] +[JsonSerializable(typeof(ToolDefinition))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(FunctionReference))] +[JsonSerializable(typeof(FunctionToolChoice))] +[JsonSerializable(typeof(CustomToolChoice))] +[JsonSerializable(typeof(CustomToolObject))] +[JsonSerializable(typeof(ResponseFormat))] +[JsonSerializable(typeof(TextResponseFormat))] +[JsonSerializable(typeof(JsonSchemaResponseFormat))] +[JsonSerializable(typeof(JsonSchemaConfiguration))] +[JsonSerializable(typeof(JsonObjectResponseFormat))] +[JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(FunctionTool))] +[JsonSerializable(typeof(FunctionDefinition))] +[JsonSerializable(typeof(CustomTool))] +[JsonSerializable(typeof(CustomToolProperties))] +[JsonSerializable(typeof(CustomToolFormat))] +[ExcludeFromCodeCoverage] +internal sealed partial class ChatCompletionsJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs new file mode 100644 index 0000000000..b009b82d29 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +/// +/// Extension methods for JSON serialization. +/// +internal static class ChatCompletionsJsonSerializerOptions +{ + /// + /// Gets the default JSON serializer options. + /// + public static JsonSerializerOptions Default { get; } = Create(); + + private static JsonSerializerOptions Create() + { + JsonSerializerOptions options = new(ChatCompletionsJsonContext.Default.Options); + options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + options.MakeReadOnly(); + return options; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs new file mode 100644 index 0000000000..5f50251f74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; + +internal static class ChatClientAgentRunOptionsConverter +{ + private static readonly JsonElement s_emptyJson = JsonDocument.Parse("{}").RootElement; + + public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request) + { + ChatOptions chatOptions = new() + { + Temperature = request.Temperature, + MaxOutputTokens = request.MaxCompletionTokens, + FrequencyPenalty = request.FrequencyPenalty, + PresencePenalty = request.PresencePenalty, + Seed = request.Seed, + TopP = request.TopP, + StopSequences = request.Stop?.SequenceList ?? [], + ResponseFormat = request.ResponseFormat?.ToChatResponseFormat() + }; + + if (request.ToolChoice is not null) + { + chatOptions.ToolMode = request.ToolChoice.ToChatToolMode(); + } + + if (request.Tools?.Count > 0) + { + chatOptions.Tools = request.Tools.Select(x => x.ToAITool()).ToList(); + } + + return new() + { + ChatOptions = chatOptions + }; + } + + private static ChatResponseFormat ToChatResponseFormat(this ResponseFormat responseFormat) + { + if (responseFormat.IsText) + { + return ChatResponseFormat.Text; + } + if (responseFormat.IsJsonObject) + { + return ChatResponseFormat.Json; + } + if (responseFormat.IsJsonSchema) + { + var schema = responseFormat.JsonSchema.JsonSchema; + return ChatResponseFormat.ForJsonSchema(schema.Schema, schema.Name, schema.Description); + } + + throw new ArgumentOutOfRangeException(nameof(responseFormat)); + } + + private static AITool ToAITool(this Tool tool) + { + if (tool is FunctionTool functionTool) + { + var function = functionTool.Function; + return AIFunctionFactory.CreateDeclaration(function.Name, function.Description, function.Parameters ?? s_emptyJson); + } + if (tool is CustomTool customTool) + { + var custom = customTool.Custom; + return new CustomAITool(custom.Name, custom.Description, custom.Format?.AdditionalProperties); + } + + throw new ArgumentOutOfRangeException(nameof(tool)); + } + + private static ChatToolMode? ToChatToolMode(this ToolChoice toolChoice) + { + if (toolChoice.IsMode) + { + return toolChoice.Mode switch + { + "auto" => ChatToolMode.Auto, + "none" => ChatToolMode.None, + "required" => ChatToolMode.RequireAny, + _ => null + }; + } + + if (toolChoice.IsAllowedTools) + { + var mode = toolChoice.AllowedTools.AllowedTools.Mode; + return mode switch + { + "auto" => ChatToolMode.Auto, + "required" => ChatToolMode.RequireAny, + _ => null + }; + } + + if (toolChoice.IsFunctionTool) + { + var function = toolChoice.FunctionTool.Function; + return ChatToolMode.RequireSpecific(function.Name); + } + + if (toolChoice.IsCustomTool) + { + var custom = toolChoice.CustomTool.Custom; + return ChatToolMode.RequireSpecific(custom.Name); + } + + throw new ArgumentOutOfRangeException(nameof(toolChoice)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs new file mode 100644 index 0000000000..f646010ac4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; + +internal static class MessageContentPartConverter +{ + public static AIContent? ToAIContent(MessageContentPart part) + { + return part switch + { + // text + TextContentPart textPart => new TextContent(textPart.Text), + + // image + ImageContentPart imagePart when !string.IsNullOrEmpty(imagePart.UrlOrData) => + imagePart.UrlOrData.StartsWith("data:", StringComparison.OrdinalIgnoreCase) + ? new DataContent(imagePart.UrlOrData, "image/*") + : new UriContent(imagePart.Url, ImageUriToMediaType(imagePart.Url)), + + // audio + AudioContentPart audioPart => + new DataContent(audioPart.InputAudio.Data, audioPart.InputAudio.Format.ToUpperInvariant() switch + { + "MP3" => "audio/mpeg", + "WAV" => "audio/wav", + "OPUS" => "audio/opus", + "AAC" => "audio/aac", + "FLAC" => "audio/flac", + "PCM16" => "audio/pcm", + _ => "audio/*" + }), + + // file + FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileId) + => new HostedFileContent(filePart.File.FileId), + FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileData) + => new DataContent(filePart.File.FileData, "application/octet-stream") { Name = filePart.File.Filename }, + + _ => null + }; + } + + private static string ImageUriToMediaType(Uri uri) + { + string absoluteUri = uri.AbsoluteUri; + return + absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : + absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" : + absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" : + absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" : + "image/*"; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs new file mode 100644 index 0000000000..ccd15d5983 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a chat completion response returned by the model, based on the provided input. +/// +internal sealed record ChatCompletion +{ + /// + /// A unique identifier for the chat completion. + /// + [JsonPropertyName("id")] + [JsonRequired] + public required string Id { get; init; } + + /// + /// The object type, which is always "chat.completion". + /// + [JsonPropertyName("object")] + public string Object { get; init; } = "chat.completion"; + + /// + /// The Unix timestamp (in seconds) of when the chat completion was created. + /// + [JsonPropertyName("created")] + [JsonRequired] + public required long Created { get; init; } + + /// + /// The model used for the chat completion. + /// + [JsonPropertyName("model")] + [JsonRequired] + public required string Model { get; init; } + + /// + /// A list of chat completion choices. Can be more than one if n is greater than 1. + /// + [JsonPropertyName("choices")] + [JsonRequired] + public required IList Choices { get; init; } + + /// + /// Usage statistics for the completion request. + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CompletionUsage? Usage { get; init; } + + /// + /// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request. + /// + [JsonPropertyName("service_tier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ServiceTier { get; init; } + + /// + /// This fingerprint represents the backend configuration that the model runs with. + /// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism. + /// + [JsonPropertyName("system_fingerprint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SystemFingerprint { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs new file mode 100644 index 0000000000..70de23e021 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a choice in a chat completion response. +/// +internal sealed record ChatCompletionChoice +{ + /// + /// The index of the choice in the list of choices. + /// + [JsonPropertyName("index")] + public required int Index { get; init; } + + /// + /// The reason the model stopped generating tokens. + /// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached, + /// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool, + /// or function_call (deprecated) if the model called a function. + /// + [JsonPropertyName("finish_reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; init; } + + /// + /// A chat completion message generated by the model. + /// + [JsonPropertyName("message")] + public required ChoiceMessage Message { get; init; } +} + +/// +/// A chat completion message generated by the model. +/// +internal sealed record ChoiceMessage +{ + /// + /// The role of the author of this message. + /// + [JsonPropertyName("role")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Role { get; set; } + + /// + /// A list of annotations for this message. Currently used for web search citations. + /// + [JsonPropertyName("annotations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Annotations { get; set; } + + /// + /// The contents of the message. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Content { get; set; } + + /// + /// The refusal message generated by the model. + /// + [JsonPropertyName("refusal")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Refusal { get; set; } + + /// + /// If the audio output modality is requested, this object contains data about the audio response from the model. + /// + [JsonPropertyName("audio")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageAudio? Audio { get; set; } + + /// + /// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model. + /// + [JsonPropertyName("function_call")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageFunctionCall? FunctionCall { get; set; } + + /// + /// The tool calls generated by the model, such as function calls. + /// + [JsonPropertyName("tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? ToolCalls { get; set; } +} + +/// +/// Audio output data in a chat completion message. +/// +internal sealed record ChoiceMessageAudio +{ + /// + /// Base64 encoded audio bytes generated by the model, in the format specified in the request. + /// + [JsonPropertyName("data")] + public string? Data { get; init; } + + /// + /// The Unix timestamp (in seconds) for when this audio response will no longer be accessible on the server for use in multi-turn conversations. + /// + [JsonPropertyName("expires_at")] + public int ExpiresAt { get; init; } + + /// + /// Unique identifier for this audio response. + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; init; } + + /// + /// Transcript of the audio generated by the model. + /// + [JsonPropertyName("transcript")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Transcript { get; init; } +} + +/// +/// Deprecated. The name and arguments of a function that should be called, as generated by the model. +/// +internal sealed record ChoiceMessageFunctionCall +{ + /// + /// The name of the function to call. + /// + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; init; } + + /// + /// The arguments to call the function with, as generated by the model in JSON format. + /// Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema. + /// Validate the arguments in your code before calling your function. + /// + [JsonPropertyName("arguments")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Arguments { get; init; } +} + +/// +/// Represents a tool call generated by the model. +/// +internal sealed record ChoiceMessageToolCall +{ + /// + /// The ID of the tool call. + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; init; } + + /// + /// The type of the tool. + /// + public string Type => "function"; + + /// + /// The function that the model called. + /// + [JsonPropertyName("function")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageFunctionCall? Function { get; set; } +} + +/// +/// An annotation for a message, used for web search citations. +/// +internal sealed record ChoiceMessageAnnotation +{ + /// + /// The type of annotation. Always 'url_citation' for web search results. + /// + [JsonPropertyName("type")] + public string Type => "url_citation"; + + /// + /// The URL citation details. + /// + [JsonPropertyName("url_citation")] + public required AnnotationUrlCitation AnnotationUrlCitation { get; init; } +} + +/// +/// A citation to a URL for a web search result. +/// +internal sealed record AnnotationUrlCitation +{ + /// + /// The character index in the message content where the citation ends. + /// + [JsonPropertyName("end_index")] + public int? EndIndex { get; init; } + + /// + /// The character index in the message content where the citation starts. + /// + [JsonPropertyName("start_index")] + public int? StartIndex { get; init; } + + /// + /// The title of the cited resource. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The URL of the cited resource. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs new file mode 100644 index 0000000000..204c5c07b3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a chunk of chat completion response returned by the model, based on the provided input. +/// +internal sealed record ChatCompletionChunk +{ + /// + /// A unique identifier for the chat completion. Each chunk has the same ID. + /// + [JsonPropertyName("id")] + [JsonRequired] + public required string Id { get; init; } + + /// + /// A list of chat completion choices. Can be more than one if n is greater than 1. + /// + [JsonPropertyName("choices")] + [JsonRequired] + public required IList Choices { get; init; } + + /// + /// The object type, which is always "chat.completion.chunk". + /// + [JsonPropertyName("object")] + public string Object => "chat.completion.chunk"; + + /// + /// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp. + /// + [JsonPropertyName("created")] + [JsonRequired] + public required long Created { get; init; } + + /// + /// The model to generate the completion. + /// + [JsonPropertyName("model")] + [JsonRequired] + public required string Model { get; init; } + + /// + /// Usage statistics for the completion request. + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CompletionUsage? Usage { get; init; } + + /// + /// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request. + /// + [JsonPropertyName("service_tier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ServiceTier { get; init; } + + /// + /// This fingerprint represents the backend configuration that the model runs with. + /// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism. + /// + [JsonPropertyName("system_fingerprint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SystemFingerprint { get; init; } +} + +internal sealed record ChatCompletionChoiceChunk +{ + /// + /// The index of the choice in the list of choices. + /// + [JsonPropertyName("index")] + public required int Index { get; init; } + + /// + /// The reason the model stopped generating tokens. + /// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached, + /// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool, or function_call (deprecated) if the model called a function. + /// + [JsonPropertyName("finish_reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; init; } + + [JsonPropertyName("delta")] + public required ChatCompletionDelta Delta { get; init; } +} + +internal sealed record ChatCompletionDelta +{ + /// + /// The contents of the chunk message. + /// + [JsonPropertyName("content")] + public string? Content { get; init; } + + /// + /// The refusal message generated by the model. + /// + [JsonPropertyName("refusal")] + public string? Refusal { get; init; } + + /// + /// The role of the author of this message. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model. + /// + [JsonPropertyName("function_call")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageFunctionCall? FunctionCall { get; set; } + + [JsonPropertyName("tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? ToolCalls { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs new file mode 100644 index 0000000000..3e9483c616 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a message in a chat completion request. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "role", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(DeveloperMessage), "developer")] +[JsonDerivedType(typeof(SystemMessage), "system")] +[JsonDerivedType(typeof(UserMessage), "user")] +[JsonDerivedType(typeof(AssistantMessage), "assistant")] +[JsonDerivedType(typeof(ToolMessage), "tool")] +[JsonDerivedType(typeof(FunctionMessage), "function")] +internal abstract record ChatCompletionRequestMessage +{ + /// + /// The role of the content. + /// + [JsonIgnore] + public abstract string Role { get; } + + /// + /// The contents of the message. + /// + [JsonPropertyName("content")] + public required MessageContent Content { get; init; } + + /// + /// Converts to a . + /// + /// A representing the message. + /// Thrown when the content is neither text nor AI contents. + public virtual ChatMessage ToChatMessage() + { + if (this.Content.IsText) + { + return new(ChatRole.User, this.Content.Text); + } + else if (this.Content.IsContents) + { + var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList(); + return new ChatMessage(ChatRole.User, aiContents!); + } + + throw new InvalidOperationException("MessageContent has no value"); + } +} + +/// +/// A developer message in a chat completion request. +/// Developer messages are used to provide instructions to the model at the system level. +/// +internal sealed record DeveloperMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "developer"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// A system message in a chat completion request. +/// System messages provide high-level instructions for the conversation. +/// +internal sealed record SystemMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "system"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// A user message in a chat completion request. +/// User messages represent input from the end user. +/// +internal sealed record UserMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "user"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// An assistant message in a chat completion request. +/// Assistant messages represent previous responses from the model, used in multi-turn conversations. +/// +internal sealed record AssistantMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "assistant"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// A tool message in a chat completion request. +/// Tool messages contain the result of a tool call made by the assistant. +/// +internal sealed record ToolMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "tool"; + + /// + /// Tool call that this message is responding to. + /// + [JsonPropertyName("tool_call_id")] + public required string ToolCallId { get; set; } +} + +/// +/// Deprecated. A function message in a chat completion request. +/// Function messages have been replaced by tool messages. +/// +internal sealed record FunctionMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "function"; + + /// + /// The name of the function to call. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Converts to a . + /// + /// A representing the message. + /// Thrown when the content is not text. + public override ChatMessage ToChatMessage() + { + if (this.Content.IsText) + { + return new(ChatRole.User, this.Content.Text); + } + + throw new InvalidOperationException("FunctionMessage Content must be text"); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs new file mode 100644 index 0000000000..3e7632bf6e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents usage statistics for a chat completion request. +/// +internal sealed record CompletionUsage +{ + public static CompletionUsage Zero { get; } = new() + { + CompletionTokens = 0, + PromptTokens = 0, + TotalTokens = 0, + CompletionTokensDetails = new() + { + AcceptedPredictionTokens = 0, + AudioTokens = 0, + ReasoningTokens = 0, + RejectedPredictionTokens = 0 + }, + PromptTokensDetails = new() + { + AudioTokens = 0, + CachedTokens = 0 + }, + }; + + /// + /// Number of tokens in the generated completion. + /// + [JsonPropertyName("completion_tokens")] + public int? CompletionTokens { get; set; } + + /// + /// Number of tokens in the prompt. + /// + [JsonPropertyName("prompt_tokens")] + public int? PromptTokens { get; set; } + + /// + /// Total number of tokens used in the request (prompt + completion). + /// + [JsonPropertyName("total_tokens")] + public int? TotalTokens { get; set; } + + /// + /// Breakdown of tokens used in the generated completion. + /// + [JsonPropertyName("completion_tokens_details")] + public required CompletionTokensDetails CompletionTokensDetails { get; set; } + + /// + /// Breakdown of tokens used in the prompt. + /// + [JsonPropertyName("prompt_tokens_details")] + public required PromptTokensDetails PromptTokensDetails { get; set; } + + public static CompletionUsage operator +(CompletionUsage left, CompletionUsage right) => new() + { + CompletionTokens = left.CompletionTokens + right.CompletionTokens, + PromptTokens = left.PromptTokens + right.PromptTokens, + TotalTokens = left.TotalTokens + right.TotalTokens, + CompletionTokensDetails = left.CompletionTokensDetails + right.CompletionTokensDetails, + PromptTokensDetails = left.PromptTokensDetails + right.PromptTokensDetails + }; +} + +/// +/// Breakdown of tokens used in a completion. +/// +internal sealed record CompletionTokensDetails +{ + /// + /// When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion. + /// + [JsonPropertyName("accepted_prediction_tokens")] + public int AcceptedPredictionTokens { get; set; } + + /// + /// Audio input tokens generated by the model. + /// + [JsonPropertyName("audio_tokens")] + public int AudioTokens { get; set; } + + /// + /// Tokens generated by the model for reasoning. + /// + [JsonPropertyName("reasoning_tokens")] + public int ReasoningTokens { get; set; } + + /// + /// When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion. + /// However, like reasoning tokens, these tokens are still counted in the total completion tokens for purposes of billing, + /// output, and context window limits. + /// + [JsonPropertyName("rejected_prediction_tokens")] + public int RejectedPredictionTokens { get; set; } + + public static CompletionTokensDetails operator +(CompletionTokensDetails left, CompletionTokensDetails right) => new() + { + AcceptedPredictionTokens = left.AcceptedPredictionTokens + right.AcceptedPredictionTokens, + AudioTokens = left.AudioTokens + right.AudioTokens, + ReasoningTokens = left.ReasoningTokens + right.ReasoningTokens, + RejectedPredictionTokens = left.RejectedPredictionTokens + right.RejectedPredictionTokens + }; +} + +/// +/// Breakdown of tokens used in the prompt. +/// +internal sealed record PromptTokensDetails +{ + /// + /// Audio input tokens present in the prompt. + /// + [JsonPropertyName("audio_tokens")] + public int AudioTokens { get; set; } + + /// + /// Cached tokens present in the prompt. + /// + [JsonPropertyName("cached_tokens")] + public int CachedTokens { get; set; } + + public static PromptTokensDetails operator +(PromptTokensDetails left, PromptTokensDetails right) => new() + { + AudioTokens = left.AudioTokens + right.AudioTokens, + CachedTokens = left.CachedTokens + right.CachedTokens + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs new file mode 100644 index 0000000000..2bcf509966 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Request to create a chat completion. +/// +internal sealed record CreateChatCompletion +{ + /// + /// A list of messages comprising the conversation so far. + /// + [JsonPropertyName("messages")] + [JsonRequired] + public required IList Messages { get; set; } + + /// + /// Model ID used to generate the response, like `gpt-4o` or `o3`. + /// + [JsonPropertyName("model")] + [JsonRequired] + public required string Model { get; set; } + + /// + /// Parameters for audio output. Required when audio output is requested with modalities: ["audio"]. + /// + [JsonPropertyName("audio")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Audio { get; set; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far. + /// + [JsonPropertyName("frequency_penalty")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? FrequencyPenalty { get; set; } + + /// + /// Deprecated in favor of tool_choice. Controls which (if any) function is called by the model. + /// + [JsonPropertyName("function_call")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [Obsolete("Deprecated in favor of ToolChoice.")] + public object? FunctionCall { get; set; } + + /// + /// Deprecated in favor of tools. A list of functions the model may generate JSON inputs for. + /// + [JsonPropertyName("functions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [Obsolete("Deprecated in favor of Tools.")] + public IList? Functions { get; set; } + + /// + /// Modify the likelihood of specified tokens appearing in the completion. + /// + [JsonPropertyName("logit_bias")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? LogitBias { get; set; } + + /// + /// Whether to return log probabilities of the output tokens or not. + /// + [JsonPropertyName("logprobs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Logprobs { get; set; } + + /// + /// An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens. + /// + [JsonPropertyName("max_completion_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxCompletionTokens { get; set; } + + /// + /// The maximum number of tokens that can be generated in the chat completion. (Deprecated in favor of max_completion_tokens) + /// + [JsonPropertyName("max_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [Obsolete("Use MaxCompletionTokens instead. This property is deprecated and not compatible with o-series models.")] + public int? MaxTokens { get; set; } + + /// + /// Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional + /// information about the object in a structured format, and querying for objects via API or the dashboard. + /// Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters. + /// + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Metadata { get; set; } + + /// + /// Types of content modalities the model can output. Can include "text" and/or "audio". + /// + [JsonPropertyName("modalities")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Modalities { get; set; } + + /// + /// How many chat completion choices to generate for each input message. + /// + [JsonPropertyName("n")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? N { get; set; } + + /// + /// Whether to enable parallel function calling during tool use. + /// + [JsonPropertyName("parallel_tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ParallelToolCalls { get; set; } + + /// + /// Configuration for a Predicted Output, which can greatly improve response times when large parts of the model response are known ahead of time. + /// + [JsonPropertyName("prediction")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Prediction { get; set; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far. + /// + [JsonPropertyName("presence_penalty")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? PresencePenalty { get; set; } + + /// + /// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. + /// + [JsonPropertyName("prompt_cache_key")] + public string? PromptCacheKey { get; init; } + + /// + /// The reasoning effort level for o-series models. Can be "low", "medium", or "high". + /// + [JsonPropertyName("reasoning_effort")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasoningEffort { get; set; } + + /// + /// An object specifying the format that the model must output. + /// + [JsonPropertyName("response_format")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResponseFormat? ResponseFormat { get; set; } + + /// + /// A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. + /// The IDs should be a string that uniquely identifies each user. We recommend hashing their username or email address, + /// in order to avoid sending us any identifying information. + /// + [JsonPropertyName("safety_identifier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SafetyIdentifier { get; set; } + + /// + /// If specified, the system will make a best effort to sample deterministically. + /// + [JsonPropertyName("seed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Seed { get; set; } + + /// + /// Specifies the processing type used for serving the request. + /// If set to 'auto', the request will be processed with the service tier configured in the Project settings. + /// If set to 'default', the request will be processed with standard pricing and performance. + /// If set to 'flex' or 'priority', the request will be processed with the corresponding service tier. + /// Defaults to 'auto'. + /// + [JsonPropertyName("service_tier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ServiceTier { get; set; } + + /// + /// Up to 4 sequences where the API will stop generating further tokens. + /// + [JsonPropertyName("stop")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StopSequences? Stop { get; set; } + + /// + /// Whether or not to store the output of this chat completion request for use in model distillation or evals products. + /// + [JsonPropertyName("store")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Store { get; set; } + + /// + /// If set to true, the model response data will be streamed to the client using server-sent events. + /// + [JsonPropertyName("stream")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Stream { get; set; } + + /// + /// Options for streaming response. Only set this when you set stream: true. + /// + [JsonPropertyName("stream_options")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? StreamOptions { get; set; } + + /// + /// What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, + /// while lower values like 0.2 will make it more focused and deterministic. + /// We generally recommend altering this or top_p but not both. Defaults to 1. + /// + [JsonPropertyName("temperature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? Temperature { get; set; } + + /// + /// Controls which (if any) tool is called by the model. + /// + [JsonPropertyName("tool_choice")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolChoice? ToolChoice { get; set; } + + /// + /// A list of tools the model may call. Can include custom tools or function tools. + /// + [JsonPropertyName("tools")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Tools { get; set; } + + /// + /// An integer between 0 and 20 specifying the number of most likely tokens to return at each token position. + /// + [JsonPropertyName("top_logprobs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? TopLogprobs { get; set; } + + /// + /// An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of + /// the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. + /// We generally recommend altering this or temperature but not both. + /// + [JsonPropertyName("top_p")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? TopP { get; set; } + + /// + /// Level of detail in the model's output. Can be "standard" or "verbose". + /// + [JsonPropertyName("verbosity")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Verbosity { get; set; } = "medium"; + + /// + /// Web search tool configuration for searching the web for relevant results. + /// + [JsonPropertyName("web_search_options")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? WebSearchOptions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs new file mode 100644 index 0000000000..001d4cc4ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Content which is a part of . +/// Can be either a string, or a list of content parts +/// +[JsonConverter(typeof(MessageContentJsonConverter))] +internal sealed record MessageContent : IEquatable +{ + private MessageContent(string text) + { + this.Text = text ?? throw new ArgumentNullException(nameof(text)); + this.Contents = null; + } + + private MessageContent(IReadOnlyList contents) + { + this.Contents = contents ?? throw new ArgumentNullException(nameof(contents)); + this.Text = null; + } + + /// + /// Creates an MessageContent from a text string. + /// + public static MessageContent FromText(string text) => new(text); + + /// + /// Creates an MessageContent from a list of MessageContentPart items. + /// + public static MessageContent FromContents(IReadOnlyList contents) => new(contents); + + /// + /// Creates an MessageContent from a list of MessageContentPart items. + /// + public static MessageContent FromContents(params MessageContentPart[] contents) => new(contents); + + /// + /// Implicit conversion from string to MessageContent. + /// + public static implicit operator MessageContent(string text) => FromText(text); + + /// + /// Implicit conversion from List to MessageContent. + /// + public static implicit operator MessageContent(List contents) => FromContents(contents); + + /// + /// Gets whether this content is text. + /// + [MemberNotNullWhen(true, nameof(Text))] + public bool IsText => this.Text is not null; + + /// + /// Gets whether this content is a list of ItemContent items. + /// + [MemberNotNullWhen(true, nameof(Contents))] + public bool IsContents => this.Contents is not null; + + /// + /// Gets the text value, or null if this is not text content. + /// + public string? Text { get; } + + /// + /// Gets the ItemContent items, or null if this is not a content list. + /// + public IReadOnlyList? Contents { get; } + + /// + public bool Equals(MessageContent? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + // Both text + if (this.Text is not null && other.Text is not null) + { + return this.Text == other.Text; + } + + // Both contents + if (this.Contents is not null + && other.Contents is not null + && this.Contents.Count == other.Contents.Count) + { + return this.Contents.SequenceEqual(other.Contents); + } + + // One is text, one is contents - not equal + return false; + } + + /// + public override int GetHashCode() + { + if (this.Text is not null) + { + return this.Text.GetHashCode(); + } + + if (this.Contents is not null) + { + return this.Contents.Count > 0 ? this.Contents[0].GetHashCode() : 0; + } + + return 0; + } +} + +/// +/// JSON converter for . +/// +internal sealed class MessageContentJsonConverter : JsonConverter +{ + public override MessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Check if it's a string + if (reader.TokenType == JsonTokenType.String) + { + var text = reader.GetString(); + return text is not null ? MessageContent.FromText(text) : null; + } + + // Check if it's an array of ItemContent + if (reader.TokenType == JsonTokenType.StartArray) + { + var contents = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart); + return contents?.Count > 0 + ? MessageContent.FromContents(contents) + : MessageContent.FromText(string.Empty); + } + + throw new JsonException($"Unexpected token type for MessageContent: {reader.TokenType}"); + } + + public override void Write(Utf8JsonWriter writer, MessageContent value, JsonSerializerOptions options) + { + if (value.IsText) + { + writer.WriteStringValue(value.Text); + } + else if (value.IsContents) + { + JsonSerializer.Serialize(writer, value.Contents, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart); + } + else + { + throw new JsonException("MessageContent has no value"); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs new file mode 100644 index 0000000000..a626190d8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a part of message content in a chat completion request. +/// Message content can be text, images, audio, or files. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(TextContentPart), "text")] +[JsonDerivedType(typeof(ImageContentPart), "image_url")] +[JsonDerivedType(typeof(AudioContentPart), "input_audio")] +[JsonDerivedType(typeof(FileContentPart), "file")] +internal abstract record MessageContentPart +{ + /// + /// The type of the content. + /// + [JsonIgnore] + public abstract string Type { get; } +} + +/// +/// A text content part in a message. +/// +internal sealed record TextContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "text"; + + /// + /// The text content. + /// + [JsonPropertyName("text")] + public required string Text { get; set; } +} + +/// +/// An image content part in a message. +/// +internal sealed record ImageContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "image_url"; + + /// + /// Details about the image URL or base64-encoded image data. + /// + [JsonPropertyName("image_url")] + public required ImageUrl ImageUrl { get; set; } + + /// + /// Gets the URL or base64-encoded data of the image. + /// + [JsonIgnore] + public string UrlOrData => this.ImageUrl.Url; + + /// + /// Gets the URL of the image. + /// + [JsonIgnore] + public Uri Url => new(this.ImageUrl.Url); +} + +/// +/// Details about an image for vision-enabled models. +/// +internal sealed record ImageUrl +{ + /// + /// Either a URL of the image or the base64 encoded image data + /// + [JsonPropertyName("url")] + public required string Url { get; set; } + + /// + /// Specifies the detail level of the image + /// + [JsonPropertyName("detail")] + public string? Detail { get; set; } +} + +/// +/// An audio content part in a message. +/// +internal sealed record AudioContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "input_audio"; + + /// + /// The input audio data. + /// + [JsonPropertyName("input_audio")] + public required InputAudio InputAudio { get; set; } +} + +/// +/// Input audio data for audio-enabled models. +/// +internal sealed record InputAudio +{ + /// + /// Base64 encoded audio data. + /// + [JsonPropertyName("data")] + public required string Data { get; set; } + + /// + /// The format of the encoded audio data. Currently supports "wav" and "mp3". + /// + [JsonPropertyName("format")] + public required string Format { get; set; } +} + +/// +/// A file content part in a message. +/// +internal sealed record FileContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "file"; + + /// + /// The input file data. + /// + [JsonPropertyName("file")] + public required InputFile File { get; set; } +} + +/// +/// Input file data for file-enabled models. +/// +internal sealed record InputFile +{ + /// + /// The base64 encoded file data, used when passing the file to the model as a string. + /// + [JsonPropertyName("file_data")] + public string? FileData { get; set; } + + /// + /// The ID of an uploaded file to use as input. + /// + [JsonPropertyName("file_id")] + public string? FileId { get; set; } + + /// + /// The name of the file, used when passing the file to the model as a string. + /// + [JsonPropertyName("filename")] + public string? Filename { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs new file mode 100644 index 0000000000..74509d94b5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Specifies the format that the model must output. +/// +[JsonConverter(typeof(ResponseFormatConverter))] +internal sealed record ResponseFormat : IEquatable +{ + private ResponseFormat(TextResponseFormat text) + { + this.Text = text ?? throw new ArgumentNullException(nameof(text)); + this.JsonSchema = null; + this.JsonObject = null; + } + + private ResponseFormat(JsonSchemaResponseFormat jsonSchema) + { + this.JsonSchema = jsonSchema ?? throw new ArgumentNullException(nameof(jsonSchema)); + this.Text = null; + this.JsonObject = null; + } + + private ResponseFormat(JsonObjectResponseFormat jsonObject) + { + this.JsonObject = jsonObject ?? throw new ArgumentNullException(nameof(jsonObject)); + this.Text = null; + this.JsonSchema = null; + } + + /// + /// Creates a ResponseFormat for text output (default). + /// + public static ResponseFormat FromText() => new(new TextResponseFormat()); + + /// + /// Creates a ResponseFormat for JSON Schema output with Structured Outputs. + /// + public static ResponseFormat FromJsonSchema(JsonSchemaResponseFormat jsonSchema) => new(jsonSchema); + + /// + /// Creates a ResponseFormat for JSON object output (older JSON mode). + /// + public static ResponseFormat FromJsonObject() => new(new JsonObjectResponseFormat()); + + /// + /// Gets whether this is a text response format. + /// + [MemberNotNullWhen(true, nameof(Text))] + public bool IsText => this.Text is not null; + + /// + /// Gets whether this is a JSON schema response format. + /// + [MemberNotNullWhen(true, nameof(JsonSchema))] + public bool IsJsonSchema => this.JsonSchema is not null; + + /// + /// Gets whether this is a JSON object response format. + /// + [MemberNotNullWhen(true, nameof(JsonObject))] + public bool IsJsonObject => this.JsonObject is not null; + + /// + /// Gets the text response format, or null if this is not a text format. + /// + public TextResponseFormat? Text { get; } + + /// + /// Gets the JSON schema response format, or null if this is not a JSON schema format. + /// + public JsonSchemaResponseFormat? JsonSchema { get; } + + /// + /// Gets the JSON object response format, or null if this is not a JSON object format. + /// + public JsonObjectResponseFormat? JsonObject { get; } + + /// + public bool Equals(ResponseFormat? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (this.Text is not null && other.Text is not null) + { + return this.Text.Equals(other.Text); + } + + if (this.JsonSchema is not null && other.JsonSchema is not null) + { + return this.JsonSchema.Equals(other.JsonSchema); + } + + if (this.JsonObject is not null && other.JsonObject is not null) + { + return this.JsonObject.Equals(other.JsonObject); + } + + return false; + } + + /// + public override int GetHashCode() + { + if (this.Text is not null) + { + return this.Text.GetHashCode(); + } + + if (this.JsonSchema is not null) + { + return this.JsonSchema.GetHashCode(); + } + + if (this.JsonObject is not null) + { + return this.JsonObject.GetHashCode(); + } + + return 0; + } +} + +/// +/// Text response format. Default response format used to generate text responses. +/// +internal sealed record TextResponseFormat +{ + /// + /// The type of response format. Always "text". + /// + [JsonPropertyName("type")] + public string Type => "text"; +} + +/// +/// JSON Schema response format. Used to generate structured JSON responses with Structured Outputs. +/// +internal sealed record JsonSchemaResponseFormat +{ + /// + /// The type of response format. Always "json_schema". + /// + [JsonPropertyName("type")] + public string Type => "json_schema"; + + /// + /// Structured Outputs configuration options, including a JSON Schema. + /// + [JsonPropertyName("json_schema")] + [JsonRequired] + public required JsonSchemaConfiguration JsonSchema { get; init; } +} + +/// +/// Configuration for JSON Schema Structured Outputs. +/// +internal sealed record JsonSchemaConfiguration +{ + /// + /// The name of the schema. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } + + /// + /// A description of the schema. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// The JSON Schema definition. + /// + [JsonPropertyName("schema")] + [JsonRequired] + public required JsonElement Schema { get; init; } + + /// + /// Whether to enable strict schema adherence. + /// + [JsonPropertyName("strict")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Strict { get; init; } +} + +/// +/// JSON object response format. An older method of generating JSON responses. +/// Using json_schema is recommended for models that support it. +/// +internal sealed record JsonObjectResponseFormat +{ + /// + /// The type of response format. Always "json_object". + /// + [JsonPropertyName("type")] + public string Type => "json_object"; +} + +/// +/// JSON converter for that handles different response format types. +/// +internal sealed class ResponseFormatConverter : JsonConverter +{ + /// + public override ResponseFormat? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (root.TryGetProperty("type", out var typeProperty)) + { + var type = typeProperty.GetString(); + return type switch + { + "text" => ResponseFormat.FromText(), + + "json_schema" => ResponseFormat.FromJsonSchema( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat)!), + + "json_object" => ResponseFormat.FromJsonObject(), + + _ => throw new JsonException($"Unknown response format type: {type}") + }; + } + + throw new JsonException("Response format object must have a 'type' property."); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ResponseFormat."); + } + + /// + public override void Write(Utf8JsonWriter writer, ResponseFormat? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.IsText) + { + JsonSerializer.Serialize(writer, value.Text, ChatCompletionsJsonContext.Default.TextResponseFormat); + } + else if (value.IsJsonSchema) + { + JsonSerializer.Serialize(writer, value.JsonSchema, ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat); + } + else if (value.IsJsonObject) + { + JsonSerializer.Serialize(writer, value.JsonObject, ChatCompletionsJsonContext.Default.JsonObjectResponseFormat); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs new file mode 100644 index 0000000000..bed3b2a320 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents stop sequences for chat completion generation. +/// Up to 4 sequences where the API will stop generating further tokens. +/// +[JsonConverter(typeof(StopSequencesConverter))] +internal sealed record StopSequences : IEquatable +{ + private StopSequences(string singleSequence) + { + this.SingleSequence = singleSequence ?? throw new ArgumentNullException(nameof(singleSequence)); + this.Sequences = null; + } + + private StopSequences(IList sequences) + { + if (sequences is null || sequences.Count == 0) + { + throw new ArgumentException("Sequences cannot be null or empty.", nameof(sequences)); + } + + if (sequences.Count > 4) + { + throw new ArgumentException("Maximum of 4 stop sequences are allowed.", nameof(sequences)); + } + + this.Sequences = sequences; + this.SingleSequence = null; + } + + /// + /// Creates a StopSequences from a single stop sequence string. + /// + public static StopSequences FromString(string sequence) => new(sequence); + + /// + /// Creates a StopSequences from a list of stop sequences. + /// + public static StopSequences FromSequences(IList sequences) => new(sequences); + + /// + /// Implicit conversion from string to StopSequences. + /// + public static implicit operator StopSequences(string sequence) => FromString(sequence); + + /// + /// Implicit conversion from string array to StopSequences. + /// + public static implicit operator StopSequences(string[] sequences) => FromSequences(sequences); + + /// + /// Implicit conversion from List to StopSequences. + /// + public static implicit operator StopSequences(List sequences) => FromSequences(sequences); + + /// + /// Gets whether this is a single stop sequence. + /// + [MemberNotNullWhen(true, nameof(SingleSequence))] + public bool IsSingleSequence => this.SingleSequence is not null; + + /// + /// Gets whether this contains multiple stop sequences. + /// + [MemberNotNullWhen(true, nameof(Sequences))] + public bool IsSequences => this.Sequences is not null; + + /// + /// Gets the single stop sequence, or null if this contains multiple sequences. + /// + public string? SingleSequence { get; } + + /// + /// Gets the list of stop sequences, or null if this is a single sequence. + /// + public IList? Sequences { get; } + + public IList SequenceList => + this.IsSingleSequence ? [this.SingleSequence] : + this.IsSequences ? this.Sequences : []; + + /// + public bool Equals(StopSequences? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + // Both single sequences + if (this.SingleSequence is not null && other.SingleSequence is not null) + { + return this.SingleSequence == other.SingleSequence; + } + + // Both sequences + if (this.Sequences is not null && other.Sequences is not null) + { + return this.Sequences.SequenceEqual(other.Sequences); + } + + // One is single, one is sequences - not equal + return false; + } + + /// + public override int GetHashCode() + { + if (this.SingleSequence is not null) + { + return this.SingleSequence.GetHashCode(); + } + + if (this.Sequences is not null) + { + return this.Sequences.Count > 0 ? this.Sequences[0].GetHashCode() : 0; + } + + return 0; + } +} + +/// +/// JSON converter for that handles string, array, and null representations. +/// +internal sealed class StopSequencesConverter : JsonConverter +{ + /// + public override StopSequences? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Handle null + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + // Handle single string + if (reader.TokenType == JsonTokenType.String) + { + string? sequence = reader.GetString(); + return sequence is not null ? StopSequences.FromString(sequence) : null; + } + + // Handle array of strings + if (reader.TokenType == JsonTokenType.StartArray) + { + var sequences = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IListString); + return sequences?.Count > 0 + ? StopSequences.FromSequences(sequences) + : StopSequences.FromString(string.Empty); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing StopSequences. Expected String, StartArray, or Null."); + } + + /// + public override void Write(Utf8JsonWriter writer, StopSequences? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.IsSingleSequence) + { + writer.WriteStringValue(value.SingleSequence); + } + else if (value.IsSequences) + { + JsonSerializer.Serialize(writer, value.Sequences, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs new file mode 100644 index 0000000000..470f7d15b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a tool that the model may call. Can be either a function tool or a custom tool. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(FunctionTool), "function")] +[JsonDerivedType(typeof(CustomTool), "custom")] +internal abstract record Tool +{ + /// + /// The type of the tool. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} + +/// +/// A function tool that can be used to generate a response. +/// +internal sealed record FunctionTool : Tool +{ + /// + /// The type of the tool. Always "function". + /// + [JsonPropertyName("type")] + public override string Type => "function"; + + /// + /// The function definition. + /// + [JsonPropertyName("function")] + [JsonRequired] + public required FunctionDefinition Function { get; init; } +} + +/// +/// Definition of a function that can be called by the model. +/// +internal sealed record FunctionDefinition +{ + /// + /// The name of the function to be called. + /// Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } + + /// + /// A description of what the function does, used by the model to choose when and how to call the function. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// The parameters the function accepts, described as a JSON Schema object. + /// Omitting parameters defines a function with an empty parameter list. + /// + [JsonPropertyName("parameters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Parameters { get; init; } + + /// + /// Whether to enable strict schema adherence when generating the function call. + /// If set to true, the model will follow the exact schema defined in the parameters field. + /// Only a subset of JSON Schema is supported when strict is true. + /// Defaults to false. + /// + [JsonPropertyName("strict")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Strict { get; init; } +} + +/// +/// A custom tool that processes input using a specified format. +/// +internal sealed record CustomTool : Tool +{ + /// + /// The type of the tool. Always "custom". + /// + [JsonPropertyName("type")] + public override string Type => "custom"; + + /// + /// Properties of the custom tool. + /// + [JsonPropertyName("custom")] + [JsonRequired] + public required CustomToolProperties Custom { get; init; } +} + +/// +/// A wrapper for MEAI +/// +internal sealed class CustomAITool : AITool +{ + public CustomAITool(string name, string? description, IReadOnlyDictionary? additionalProperties) + : base() + { + this.Name = name; + this.Description = description ?? string.Empty; + this.AdditionalProperties = additionalProperties ?? new Dictionary(); + } + + public override string Name { get; } + public override string Description { get; } + public override IReadOnlyDictionary AdditionalProperties { get; } +} + +/// +/// Properties of a custom tool. +/// +internal sealed record CustomToolProperties +{ + /// + /// The name of the custom tool, used to identify it in tool calls. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } + + /// + /// Optional description of the custom tool, used to provide more context. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// The input format for the custom tool. Default is unconstrained text. + /// + [JsonPropertyName("format")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CustomToolFormat? Format { get; init; } +} + +/// +/// The input format for a custom tool. +/// +internal sealed record CustomToolFormat +{ + /// + /// The type of format. Can be various schema types. + /// + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; init; } + + /// + /// Additional format properties (schema definition). + /// + [JsonExtensionData] + public Dictionary? AdditionalProperties { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs new file mode 100644 index 0000000000..a5dcc3ff25 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs @@ -0,0 +1,384 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Controls which (if any) tool is called by the model. +/// +[JsonConverter(typeof(ToolChoiceConverter))] +internal sealed record ToolChoice : IEquatable +{ + private ToolChoice(string mode) + { + this.Mode = mode ?? throw new ArgumentNullException(nameof(mode)); + this.AllowedTools = null; + this.FunctionTool = null; + this.CustomTool = null; + } + + private ToolChoice(AllowedToolsChoice allowedTools) + { + this.AllowedTools = allowedTools ?? throw new ArgumentNullException(nameof(allowedTools)); + this.Mode = null; + this.FunctionTool = null; + this.CustomTool = null; + } + + private ToolChoice(FunctionToolChoice functionTool) + { + this.FunctionTool = functionTool ?? throw new ArgumentNullException(nameof(functionTool)); + this.Mode = null; + this.AllowedTools = null; + this.CustomTool = null; + } + + private ToolChoice(CustomToolChoice customTool) + { + this.CustomTool = customTool ?? throw new ArgumentNullException(nameof(customTool)); + this.Mode = null; + this.AllowedTools = null; + this.FunctionTool = null; + } + + /// + /// Creates a ToolChoice from a mode string ("none", "auto", or "required"). + /// + public static ToolChoice FromMode(string mode) => new(mode); + + /// + /// Creates a ToolChoice that constrains tools to a pre-defined set. + /// + public static ToolChoice FromAllowedTools(AllowedToolsChoice allowedTools) => new(allowedTools); + + /// + /// Creates a ToolChoice that forces the model to call a specific function. + /// + public static ToolChoice FromFunction(FunctionToolChoice functionTool) => new(functionTool); + + /// + /// Creates a ToolChoice that forces the model to call a specific custom tool. + /// + public static ToolChoice FromCustom(CustomToolChoice customTool) => new(customTool); + + /// + /// Implicit conversion from string to ToolChoice. + /// + public static implicit operator ToolChoice(string mode) => FromMode(mode); + + /// + /// Gets whether this is a mode string. + /// + [MemberNotNullWhen(true, nameof(Mode))] + public bool IsMode => this.Mode is not null; + + /// + /// Gets whether this is an allowed tools configuration. + /// + [MemberNotNullWhen(true, nameof(AllowedTools))] + public bool IsAllowedTools => this.AllowedTools is not null; + + /// + /// Gets whether this is a function tool choice. + /// + [MemberNotNullWhen(true, nameof(FunctionTool))] + public bool IsFunctionTool => this.FunctionTool is not null; + + /// + /// Gets whether this is a custom tool choice. + /// + [MemberNotNullWhen(true, nameof(CustomTool))] + public bool IsCustomTool => this.CustomTool is not null; + + /// + /// Gets the mode string, or null if this is not a mode. + /// + public string? Mode { get; } + + /// + /// Gets the allowed tools configuration, or null if this is not an allowed tools choice. + /// + public AllowedToolsChoice? AllowedTools { get; } + + /// + /// Gets the function tool choice, or null if this is not a function tool choice. + /// + public FunctionToolChoice? FunctionTool { get; } + + /// + /// Gets the custom tool choice, or null if this is not a custom tool choice. + /// + public CustomToolChoice? CustomTool { get; } + + /// + public bool Equals(ToolChoice? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (this.Mode is not null && other.Mode is not null) + { + return this.Mode == other.Mode; + } + + if (this.AllowedTools is not null && other.AllowedTools is not null) + { + return this.AllowedTools.Equals(other.AllowedTools); + } + + if (this.FunctionTool is not null && other.FunctionTool is not null) + { + return this.FunctionTool.Equals(other.FunctionTool); + } + + if (this.CustomTool is not null && other.CustomTool is not null) + { + return this.CustomTool.Equals(other.CustomTool); + } + + return false; + } + + /// + public override int GetHashCode() + { + if (this.Mode is not null) + { + return this.Mode.GetHashCode(); + } + + if (this.AllowedTools is not null) + { + return this.AllowedTools.GetHashCode(); + } + + if (this.FunctionTool is not null) + { + return this.FunctionTool.GetHashCode(); + } + + if (this.CustomTool is not null) + { + return this.CustomTool.GetHashCode(); + } + + return 0; + } +} + +/// +/// Constrains the tools available to the model to a pre-defined set. +/// +internal sealed record AllowedToolsChoice +{ + /// + /// The type of tool choice. Always "allowed_tools". + /// + [JsonPropertyName("type")] + public string Type => "allowed_tools"; + + /// + /// Constrains the tools available to the model to a pre-defined set. + /// + [JsonPropertyName("allowed_tools")] + [JsonRequired] + public required AllowedToolsConfiguration AllowedTools { get; init; } +} + +/// +/// Configuration for allowed tools. +/// +internal sealed record AllowedToolsConfiguration +{ + /// + /// Constrains the tools available to the model to a pre-defined set. + /// auto allows the model to pick from among the allowed tools and generate a message. + /// required requires the model to call one or more of the allowed tools. + /// + [JsonPropertyName("mode")] + [JsonRequired] + public required string Mode { get; init; } + + /// + /// A list of tool definitions that the model should be allowed to call. + /// + [JsonPropertyName("tools")] + [JsonRequired] + public required IList Tools { get; init; } +} + +/// +/// A tool definition in the allowed tools list. +/// +internal sealed record ToolDefinition +{ + /// + /// The type of tool (e.g., "function" or "custom"). + /// + [JsonPropertyName("type")] + [JsonRequired] + public required string Type { get; init; } + + /// + /// The function details if type is "function". + /// + [JsonPropertyName("function")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FunctionReference? Function { get; init; } +} + +/// +/// A reference to a function by name. +/// +internal sealed record FunctionReference +{ + /// + /// The name of the function. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } +} + +/// +/// Specifies a function tool the model should use. +/// +internal sealed record FunctionToolChoice +{ + /// + /// The type of tool. Always "function". + /// + [JsonPropertyName("type")] + public string Type => "function"; + + /// + /// The function to call. + /// + [JsonPropertyName("function")] + [JsonRequired] + public required FunctionReference Function { get; init; } +} + +/// +/// Specifies a custom tool the model should use. +/// +internal sealed record CustomToolChoice +{ + /// + /// The type of tool. Always "custom". + /// + [JsonPropertyName("type")] + public string Type => "custom"; + + /// + /// The custom tool configuration. + /// + [JsonPropertyName("custom")] + [JsonRequired] + public required CustomToolObject Custom { get; init; } +} + +/// +/// A reference to a custom tool object. +/// +internal sealed record CustomToolObject +{ + /// + /// The name of the function. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } +} + +/// +/// JSON converter for that handles string and object representations. +/// +internal sealed class ToolChoiceConverter : JsonConverter +{ + /// + public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + string? mode = reader.GetString(); + return mode is not null ? ToolChoice.FromMode(mode) : null; + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (root.TryGetProperty("type", out var typeProperty)) + { + var type = typeProperty.GetString(); + return type switch + { + "allowed_tools" => ToolChoice.FromAllowedTools( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.AllowedToolsChoice)!), + + "function" => ToolChoice.FromFunction( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.FunctionToolChoice)!), + + "custom" => ToolChoice.FromCustom( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.CustomToolChoice)!), + + _ => throw new JsonException($"Unknown tool choice type: {type}") + }; + } + + throw new JsonException("Tool choice object must have a 'type' property."); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ToolChoice."); + } + + /// + public override void Write(Utf8JsonWriter writer, ToolChoice? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.IsMode) + { + writer.WriteStringValue(value.Mode); + } + else if (value.IsAllowedTools) + { + JsonSerializer.Serialize(writer, value.AllowedTools, ChatCompletionsJsonContext.Default.AllowedToolsChoice); + } + else if (value.IsFunctionTool) + { + JsonSerializer.Serialize(writer, value.FunctionTool, ChatCompletionsJsonContext.Default.FunctionToolChoice); + } + else if (value.IsCustomTool) + { + JsonSerializer.Serialize(writer, value.CustomTool, ChatCompletionsJsonContext.Default.CustomToolChoice); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs deleted file mode 100644 index 36534c637c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Reflection; -using Microsoft.Shared.Diagnostics; -using OpenAI.Chat; - -namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; - -[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")] -internal static class ChatCompletionsOptionsExtensions -{ - private static readonly Func s_getStreamNullable; - private static readonly Func> s_getMessages; - - static ChatCompletionsOptionsExtensions() - { - // OpenAI SDK does not have a simple way to get the input as a c# object. - // However, it does parse most of the interesting fields into internal properties of `ChatCompletionsOptions` object. - - // --- Stream (internal bool? Stream { get; set; }) --- - const string StreamPropName = "Stream"; - var streamProp = typeof(ChatCompletionOptions).GetProperty(StreamPropName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, StreamPropName); - var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{StreamPropName} getter not found."); - - s_getStreamNullable = streamGetter.CreateDelegate>(); - - // --- Messages (internal IList Messages { get; set; }) --- - const string InputPropName = "Messages"; - var inputProp = typeof(ChatCompletionOptions).GetProperty(InputPropName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, InputPropName); - var inputGetter = inputProp.GetGetMethod(nonPublic: true) - ?? throw new MissingMethodException($"{InputPropName} getter not found."); - - s_getMessages = inputGetter.CreateDelegate>>(); - } - - public static IList GetMessages(this ChatCompletionOptions options) - { - Throw.IfNull(options); - return s_getMessages(options); - } - - public static bool GetStream(this ChatCompletionOptions options) - { - Throw.IfNull(options); - return s_getStreamNullable(options) ?? false; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs index 5fe39019fc..3fcc9cad27 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -1,16 +1,15 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.ClientModel.Primitives; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; -using Microsoft.AspNetCore.Http; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using OpenAI.Chat; namespace Microsoft.AspNetCore.Builder; @@ -20,47 +19,54 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . /// /// The to add the OpenAI ChatCompletions endpoints to. - /// The name of the AI agent service registered in the dependency injection container. This name is used to resolve the instance from the keyed services. + /// The builder for to map the OpenAI ChatCompletions endpoints for. + public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder) + => MapOpenAIChatCompletions(endpoints, agentBuilder, path: null); + + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The builder for to map the OpenAI ChatCompletions endpoints for. /// Custom route path for the chat completions endpoint. - public static void MapOpenAIChatCompletions( + public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path) + { + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); + return MapOpenAIChatCompletions(endpoints, agent, path); + } + + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The instance to map the OpenAI ChatCompletions endpoints for. + public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, AIAgent agent) + => MapOpenAIChatCompletions(endpoints, agent, path: null); + + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The instance to map the OpenAI ChatCompletions endpoints for. + /// Custom route path for the chat completions endpoint. + public static IEndpointConventionBuilder MapOpenAIChatCompletions( this IEndpointRouteBuilder endpoints, - string agentName, - [StringSyntax("Route")] string? path = null) + AIAgent agent, + [StringSyntax("Route")] string? path) { ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agentName); - if (path is null) - { - ValidateAgentName(agentName); - } + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent.Name)); + ValidateAgentName(agent.Name); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - - path ??= $"/{agentName}/v1/chat/completions"; - var chatCompletionsRouteGroup = endpoints.MapGroup(path); - MapChatCompletions(chatCompletionsRouteGroup, agent); - } - - private static void MapChatCompletions(IEndpointRouteBuilder routeGroup, AIAgent agent) - { + path ??= $"/{agent.Name}/v1/chat/completions"; + var group = endpoints.MapGroup(path); var endpointAgentName = agent.DisplayName; - var chatCompletionsProcessor = new AIAgentChatCompletionsProcessor(agent); - routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) => - { - var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false); + group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken) + => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false)) + .WithName(endpointAgentName + "/CreateChatCompletion"); - var chatCompletionOptions = new ChatCompletionOptions(); - var chatCompletionOptionsJsonModel = chatCompletionOptions as IJsonModel; - Debug.Assert(chatCompletionOptionsJsonModel is not null); - - chatCompletionOptions = chatCompletionOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json); - if (chatCompletionOptions is null) - { - return Results.BadRequest("Invalid request payload."); - } - - return await chatCompletionsProcessor.CreateChatCompletionAsync(chatCompletionOptions, cancellationToken).ConfigureAwait(false); - }).WithName(endpointAgentName + "/CreateChatCompletion"); + return group; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs index f4bfeb2578..dc6a9c5ed0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs @@ -8,10 +8,24 @@ using Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.Hosting; /// -/// Extension methods for to configure OpenAI Responses support. +/// Extension methods for to configure OpenAI support. /// public static class MicrosoftAgentAIHostingOpenAIHostApplicationBuilderExtensions { + /// + /// Adds support for exposing instances via OpenAI ChatCompletions. + /// + /// The to configure. + /// The for method chaining. + public static IHostApplicationBuilder AddOpenAIChatCompletions(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOpenAIChatCompletions(); + + return builder; + } + /// /// Adds support for exposing instances via OpenAI Responses. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs new file mode 100644 index 0000000000..6f2e1017d6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Shared helpers to generate IDs. +/// +internal static partial class IdGeneratorHelpers +{ +#if NET9_0_OR_GREATER + [GeneratedRegex("^[A-Za-z0-9]+$")] + private static partial Regex WatermarkRegex(); +#else + private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled); + private static Regex WatermarkRegex() => s_watermarkRegex; +#endif + + /// + /// Generates a new ID with a structured format that includes a partition key. + /// + /// The prefix to add to the ID, typically indicating the resource type. + /// The length of the random entropy string in the ID. + /// The length of the partition key if generating a new one. + /// Optional additional text to insert between the prefix and the entropy. + /// Optional text to insert in the middle of the entropy string for traceability. + /// The delimiter character used to separate parts of the ID. + /// An explicit partition key to use. When provided, this value will be used instead of generating a new one. + /// An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one. + /// A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}". + /// Thrown when the watermark contains non-alphanumeric characters. + public static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "", + string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "") + { + ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1); + var entropy = GetRandomString(stringLength); + + string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength); + + if (!string.IsNullOrEmpty(watermark)) + { + if (!WatermarkRegex().IsMatch(watermark)) + { + throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}", + nameof(watermark)); + } + + entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}"; + } + + infix ??= ""; + prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : ""; + return $"{prefix}{infix}{entropy}{pKey}"; + } + + /// + /// Generates a secure random alphanumeric string of the specified length. + /// + /// The desired length of the random string. + /// A random alphanumeric string. + /// Thrown when stringLength is less than 1. + public static string GetRandomString(int stringLength) => + RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength); + + /// + /// Extracts the partition key from an existing ID, or returns null if extraction fails. + /// + /// The ID to extract the partition key from. + /// The length of the random entropy string in the ID. + /// The length of the partition key if generating a new one. + /// The delimiter character used in the ID. + /// The partition key if successfully extracted; otherwise, null. + public static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16, + string delimiter = "_") + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + + var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + return null; + } + + if (parts[1].Length < stringLength + partitionKeyLength) + { + return null; + } + + // get last partitionKeyLength characters from the last part as the partition key + return parts[1][^partitionKeyLength..]; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs index 63ec1a85bd..c532390371 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.Security.Cryptography; -using System.Text.RegularExpressions; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; @@ -14,14 +11,6 @@ internal sealed partial class IdGenerator { private readonly string _partitionId; -#if NET9_0_OR_GREATER - [GeneratedRegex("^[A-Za-z0-9]+$")] - private static partial Regex WatermarkRegex(); -#else - private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled); - private static Regex WatermarkRegex() => s_watermarkRegex; -#endif - /// /// Initializes a new instance of the class. /// @@ -29,9 +18,9 @@ internal sealed partial class IdGenerator /// The conversation ID. public IdGenerator(string? responseId, string? conversationId) { - this.ResponseId = responseId ?? NewId("resp"); - this.ConversationId = conversationId ?? NewId("conv"); - this._partitionId = GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty; + this.ResponseId = responseId ?? IdGeneratorHelpers.NewId("resp"); + this.ConversationId = conversationId ?? IdGeneratorHelpers.NewId("conv"); + this._partitionId = IdGeneratorHelpers.GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty; } /// @@ -64,7 +53,7 @@ internal sealed partial class IdGenerator public string Generate(string? category = null) { var prefix = string.IsNullOrEmpty(category) ? "id" : category; - return NewId(prefix, partitionKey: this._partitionId); + return IdGeneratorHelpers.NewId(prefix, partitionKey: this._partitionId); } /// @@ -90,81 +79,4 @@ internal sealed partial class IdGenerator /// /// A reasoning ID. public string GenerateReasoningId() => this.Generate("rs"); - - /// - /// Generates a new ID with a structured format that includes a partition key. - /// - /// The prefix to add to the ID, typically indicating the resource type. - /// The length of the random entropy string in the ID. - /// The length of the partition key if generating a new one. - /// Optional additional text to insert between the prefix and the entropy. - /// Optional text to insert in the middle of the entropy string for traceability. - /// The delimiter character used to separate parts of the ID. - /// An explicit partition key to use. When provided, this value will be used instead of generating a new one. - /// An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one. - /// A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}". - /// Thrown when the watermark contains non-alphanumeric characters. - private static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "", - string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "") - { - ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1); - var entropy = GetRandomString(stringLength); - - string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength); - - if (!string.IsNullOrEmpty(watermark)) - { - if (!WatermarkRegex().IsMatch(watermark)) - { - throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}", - nameof(watermark)); - } - - entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}"; - } - - infix ??= ""; - prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : ""; - return $"{prefix}{infix}{entropy}{pKey}"; - } - - /// - /// Generates a secure random alphanumeric string of the specified length. - /// - /// The desired length of the random string. - /// A random alphanumeric string. - /// Thrown when stringLength is less than 1. - private static string GetRandomString(int stringLength) => - RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength); - - /// - /// Extracts the partition key from an existing ID, or returns null if extraction fails. - /// - /// The ID to extract the partition key from. - /// The length of the random entropy string in the ID. - /// The length of the partition key if generating a new one. - /// The delimiter character used in the ID. - /// The partition key if successfully extracted; otherwise, null. - private static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16, - string delimiter = "_") - { - if (string.IsNullOrEmpty(id)) - { - return null; - } - - var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 2) - { - return null; - } - - if (parts[1].Length < stringLength + partitionKeyLength) - { - return null; - } - - // get last partitionKeyLength characters from the last part as the partition key - return parts[1][^partitionKeyLength..]; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs index 7fab6586a9..d4ea17e912 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs @@ -2,16 +2,31 @@ using System; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; using Microsoft.Agents.AI.Hosting.OpenAI.Responses; using Microsoft.AspNetCore.Http.Json; namespace Microsoft.Extensions.DependencyInjection; /// -/// Extension methods for to configure OpenAI Responses support. +/// Extension methods for to configure OpenAI support. /// public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions { + /// + /// Adds support for exposing instances via OpenAI ChatCompletions. + /// + /// The to configure. + /// The for method chaining. + public static IServiceCollection AddOpenAIChatCompletions(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(options => options.SerializerOptions.TypeInfoResolverChain.Add(ChatCompletionsJsonSerializerOptions.Default.TypeInfoResolver!)); + + return services; + } + /// /// Adds support for exposing instances via OpenAI Responses. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs index eb6de34fff..18e67267d6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -22,16 +23,19 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests; /// public abstract class ConformanceTestBase : IAsyncDisposable { - protected const string TracesBasePath = "ConformanceTraces/Responses"; + protected const string TracesBasePath = "ConformanceTraces"; + protected const string ResponsesTracesDirectory = "Responses"; + protected const string ChatCompletionsTracesDirectory = "ChatCompletions"; + private WebApplication? _app; private HttpClient? _httpClient; /// /// Loads a JSON file from the conformance traces directory. /// - protected static string LoadTraceFile(string relativePath) + protected static string LoadTraceFile(string directory, string relativePath) { - var fullPath = Path.Combine(TracesBasePath, relativePath); + var fullPath = Path.Combine(TracesBasePath, directory, relativePath); if (!File.Exists(fullPath)) { @@ -41,12 +45,33 @@ public abstract class ConformanceTestBase : IAsyncDisposable return File.ReadAllText(fullPath); } + /// + /// Loads a JSON file from the conformance traces directory. + /// + protected static string LoadResponsesTraceFile(string relativePath) + => LoadTraceFile(ResponsesTracesDirectory, relativePath); + /// /// Loads a JSON document from the conformance traces directory. /// - protected static JsonDocument LoadTraceDocument(string relativePath) + protected static JsonDocument LoadResponsesTraceDocument(string relativePath) { - var json = LoadTraceFile(relativePath); + var json = LoadResponsesTraceFile(relativePath); + return JsonDocument.Parse(json); + } + + /// + /// Loads a JSON file from the conformance traces directory. + /// + protected static string LoadChatCompletionsTraceFile(string relativePath) + => LoadTraceFile(ChatCompletionsTracesDirectory, relativePath); + + /// + /// Loads a JSON document from the conformance traces directory. + /// + protected static JsonDocument LoadChatCompletionsTraceDocument(string relativePath) + { + var json = LoadChatCompletionsTraceFile(relativePath); return JsonDocument.Parse(json); } @@ -61,6 +86,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable } } + /// + /// Asserts that a JSON element has any of the passed string values. + /// + protected static void AssertJsonPropertyEquals(JsonElement element, string propertyName, params string[] anyOfValues) + { + AssertJsonPropertyExists(element, propertyName); + var actualValue = element.GetProperty(propertyName).GetString(); + + if (!anyOfValues.Contains(actualValue)) + { + throw new Xunit.Sdk.XunitException($"Property '{propertyName}': expected any of '{string.Join("; ", anyOfValues)}', got '{actualValue}'"); + } + } + /// /// Asserts that a JSON element has a specific string value. /// @@ -75,6 +114,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable } } + /// + /// Asserts that a JSON element has a specific string value. + /// + protected static void AssertJsonPropertyEquals(JsonElement element, string propertyName, float expectedValue) + { + AssertJsonPropertyExists(element, propertyName); + var actualValue = element.GetProperty(propertyName).GetDouble(); + + if (actualValue != expectedValue) + { + throw new Xunit.Sdk.XunitException($"Property '{propertyName}': expected '{expectedValue}', got '{actualValue}'"); + } + } + /// /// Asserts that a JSON element has a specific integer value. /// @@ -141,10 +194,12 @@ public abstract class ConformanceTestBase : IAsyncDisposable builder.Services.AddKeyedSingleton("chat-client", mockChatClient); builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client"); builder.AddOpenAIResponses(); + builder.AddOpenAIChatCompletions(); this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIChatCompletions(agent); await this._app.StartAsync(); @@ -171,10 +226,12 @@ public abstract class ConformanceTestBase : IAsyncDisposable builder.Services.AddKeyedSingleton("chat-client", mockChatClient); builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client"); builder.AddOpenAIResponses(); + builder.AddOpenAIChatCompletions(); this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIChatCompletions(agent); await this._app.StartAsync(); @@ -188,12 +245,21 @@ public abstract class ConformanceTestBase : IAsyncDisposable /// /// Sends a POST request with JSON content to the test server. /// - protected async Task SendRequestAsync(HttpClient client, string agentName, string requestJson) + protected async Task SendResponsesRequestAsync(HttpClient client, string agentName, string requestJson) { StringContent content = new(requestJson, Encoding.UTF8, "application/json"); return await client.PostAsync(new Uri($"/{agentName}/v1/responses", UriKind.Relative), content); } + /// + /// Sends a POST request with JSON content to the test server. + /// + protected async Task SendChatCompletionRequestAsync(HttpClient client, string agentName, string requestJson) + { + StringContent content = new(requestJson, Encoding.UTF8, "application/json"); + return await client.PostAsync(new Uri($"/{agentName}/v1/chat/completions", UriKind.Relative), content); + } + /// /// Parses the response JSON and returns a JsonDocument. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json new file mode 100644 index 0000000000..0dc658fcd2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json @@ -0,0 +1,12 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Hello, how are you?" + } + ], + "max_completion_tokens": 100, + "temperature": 1.0, + "top_p": 1.0 +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json new file mode 100644 index 0000000000..e344e37511 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-AaBbCcDdEeFfGg", + "object": "chat.completion", + "created": 1730371200, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! I'm doing well, thank you. How about you?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 13, + "completion_tokens": 14, + "total_tokens": 27, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_1234567890" +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json new file mode 100644 index 0000000000..9d3defd0e7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json @@ -0,0 +1,34 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "What's the weather in San Francisco?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "enum": [ "celsius", "fahrenheit" ], + "description": "The unit of temperature" + } + }, + "required": [ "location" ] + } + } + } + ], + "tool_choice": "auto" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json new file mode 100644 index 0000000000..bfe11a2e07 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json @@ -0,0 +1,43 @@ +{ + "id": "chatcmpl-DEF456", + "object": "chat.completion", + "created": 1730371250, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_abc123xyz", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"San Francisco, CA\",\"unit\":\"fahrenheit\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 85, + "completion_tokens": 18, + "total_tokens": 103, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_1234567890" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json new file mode 100644 index 0000000000..6e30064210 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json @@ -0,0 +1,36 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant that outputs JSON." + }, + { + "role": "user", + "content": "Provide information about a person named John Doe, age 30, who is a software engineer." + } + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "person_info", + "strict": true, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "number" + }, + "occupation": { + "type": "string" + } + }, + "required": [ "name", "age", "occupation" ], + "additionalProperties": false + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json new file mode 100644 index 0000000000..72b06686d5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-MNO345", + "object": "chat.completion", + "created": 1730371400, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"name\":\"John Doe\",\"age\":30,\"occupation\":\"software engineer\"}" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 45, + "completion_tokens": 18, + "total_tokens": 63, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_5544332211" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json new file mode 100644 index 0000000000..c749590b15 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json @@ -0,0 +1,18 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "What is 2+2?" + }, + { + "role": "assistant", + "content": "2+2 equals 4." + }, + { + "role": "user", + "content": "What about 3+3?" + } + ], + "max_completion_tokens": 50 +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json new file mode 100644 index 0000000000..b695db84ce --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-JKL012", + "object": "chat.completion", + "created": 1730371350, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "3+3 equals 6." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 35, + "completion_tokens": 8, + "total_tokens": 43, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_1122334455" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json new file mode 100644 index 0000000000..f224d8d953 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json @@ -0,0 +1,12 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Write a short poem about AI." + } + ], + "max_completion_tokens": 150, + "temperature": 1.0, + "stream": true +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt new file mode 100644 index 0000000000..aa0261cb80 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt @@ -0,0 +1,21 @@ +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":"In"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" circuits"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" bright"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":","},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" minds"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" take"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" flight"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":"."},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":12,"total_tokens":24,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + +data: [DONE] \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json new file mode 100644 index 0000000000..416939d213 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json @@ -0,0 +1,14 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant that speaks like a pirate." + }, + { + "role": "user", + "content": "Tell me about the ocean." + } + ], + "max_completion_tokens": 100 +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json new file mode 100644 index 0000000000..ddda144675 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-GHI789", + "object": "chat.completion", + "created": 1730371300, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Ahoy, matey! The ocean be a vast, mysterious realm full of treasures and creatures!" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 28, + "completion_tokens": 20, + "total_tokens": 48, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_9876543210" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs index 80b2f6781d..5a8f4ea442 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs @@ -36,7 +36,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase ]); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -76,7 +76,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase ]); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -111,7 +111,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase ]); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -144,7 +144,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, ErrorMessage); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -179,7 +179,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, ErrorMessage); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -213,7 +213,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, "Error message"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -240,7 +240,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentAgentAsync(AgentName, ImageUrl, isDataUri: false); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -264,7 +264,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentAgentAsync(AgentName, DataUri, isDataUri: true); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -289,7 +289,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentWithDetailAgentAsync(AgentName, ImageUrl, Detail); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -313,7 +313,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentAgentAsync(AgentName, "https://example.com/test.png", isDataUri: false); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -339,7 +339,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, "audio/mpeg"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -364,7 +364,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, "audio/wav"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -390,7 +390,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, mediaType); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -415,7 +415,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateHostedFileContentAgentAsync(AgentName, FileId); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -438,7 +438,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateHostedFileContentAgentAsync(AgentName, "file-xyz789"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -465,7 +465,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateFileContentAgentAsync(AgentName, FileDataUri, Filename); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -490,7 +490,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateFileContentAgentAsync(AgentName, FileDataUri, null); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -516,7 +516,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateMixedContentAgentAsync(AgentName); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -535,7 +535,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorAndTextContentAgentAsync(AgentName); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj index ff200a7296..5d081b452e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj @@ -1,4 +1,4 @@ - + $(ProjectsCoreTargetFrameworks) @@ -27,4 +27,16 @@ + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs new file mode 100644 index 0000000000..b777db0ce5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Tests; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Conformance tests for OpenAI Chat Completions API implementation behavior. +/// Tests use real API traces to ensure our implementation produces responses +/// that match OpenAI's wire format when processing actual requests through the server. +/// +public sealed class OpenAIChatCompletionsConformanceTests : ConformanceTestBase +{ + [Fact] + public async Task BasicRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("basic/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("basic/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + // Get the expected response text from the trace to use as mock response + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("basic-agent", "You are a helpful assistant.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "basic-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request to verify it was sent correctly + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Verify request was properly formatted (structure check) + AssertJsonPropertyEquals(request, "model", "gpt-4o-mini"); + AssertJsonPropertyExists(request, "messages"); + AssertJsonPropertyEquals(request, "max_completion_tokens", 100); + AssertJsonPropertyEquals(request, "temperature", 1.0f); + AssertJsonPropertyEquals(request, "top_p", 1.0f); + + var messages = request.GetProperty("messages"); + Assert.Equal(JsonValueKind.Array, messages.ValueKind); + Assert.True(messages.GetArrayLength() > 0, "Messages array should not be empty"); + + var firstMessage = messages[0]; + AssertJsonPropertyEquals(firstMessage, "role", "user"); + AssertJsonPropertyEquals(firstMessage, "content", "Hello, how are you?"); + + // Assert - Response metadata (IDs and timestamps are dynamic, just verify structure) + AssertJsonPropertyExists(response, "id"); + AssertJsonPropertyEquals(response, "object", "chat.completion"); + AssertJsonPropertyExists(response, "created"); + AssertJsonPropertyExists(response, "model"); + + var id = response.GetProperty("id").GetString(); + Assert.NotNull(id); + Assert.StartsWith("chatcmpl-", id); + + var createdAt = response.GetProperty("created").GetInt64(); + Assert.True(createdAt > 0, "created should be a positive unix timestamp"); + + var model = response.GetProperty("model").GetString(); + Assert.NotNull(model); + Assert.StartsWith("gpt-4o-mini", model); + + // Assert - Choices array structure + AssertJsonPropertyExists(response, "choices"); + var choices = response.GetProperty("choices"); + Assert.Equal(JsonValueKind.Array, choices.ValueKind); + Assert.True(choices.GetArrayLength() > 0, "Choices array should not be empty"); + + // Assert - Choice structure + var firstChoice = choices[0]; + AssertJsonPropertyExists(firstChoice, "index"); + AssertJsonPropertyEquals(firstChoice, "index", 0); + AssertJsonPropertyExists(firstChoice, "message"); + AssertJsonPropertyExists(firstChoice, "finish_reason"); + + var finishReason = firstChoice.GetProperty("finish_reason").GetString(); + Assert.NotNull(finishReason); + Assert.Contains(finishReason, collection: ["stop", "length", "content_filter", "tool_calls"]); + + // Assert - Message structure + var message = firstChoice.GetProperty("message"); + AssertJsonPropertyExists(message, "role"); + AssertJsonPropertyEquals(message, "role", "assistant"); + AssertJsonPropertyExists(message, "content"); + + var content = message.GetProperty("content").GetString(); + Assert.NotNull(content); + Assert.Equal(expectedText, content); // Verify actual content matches expected + + // Assert - Usage statistics + AssertJsonPropertyExists(response, "usage"); + var usage = response.GetProperty("usage"); + AssertJsonPropertyExists(usage, "prompt_tokens"); + AssertJsonPropertyExists(usage, "completion_tokens"); + AssertJsonPropertyExists(usage, "total_tokens"); + + var promptTokens = usage.GetProperty("prompt_tokens").GetInt32(); + var completionTokens = usage.GetProperty("completion_tokens").GetInt32(); + var totalTokens = usage.GetProperty("total_tokens").GetInt32(); + + Assert.True(promptTokens > 0, "prompt_tokens should be positive"); + Assert.True(completionTokens > 0, "completion_tokens should be positive"); + Assert.Equal(promptTokens + completionTokens, totalTokens); + + // Assert - Usage details + AssertJsonPropertyExists(usage, "prompt_tokens_details"); + var promptDetails = usage.GetProperty("prompt_tokens_details"); + AssertJsonPropertyExists(promptDetails, "cached_tokens"); + AssertJsonPropertyExists(promptDetails, "audio_tokens"); + Assert.True(promptDetails.GetProperty("cached_tokens").GetInt32() >= 0); + Assert.True(promptDetails.GetProperty("audio_tokens").GetInt32() >= 0); + + AssertJsonPropertyExists(usage, "completion_tokens_details"); + var completionDetails = usage.GetProperty("completion_tokens_details"); + AssertJsonPropertyExists(completionDetails, "reasoning_tokens"); + AssertJsonPropertyExists(completionDetails, "audio_tokens"); + AssertJsonPropertyExists(completionDetails, "accepted_prediction_tokens"); + AssertJsonPropertyExists(completionDetails, "rejected_prediction_tokens"); + Assert.True(completionDetails.GetProperty("reasoning_tokens").GetInt32() >= 0); + Assert.True(completionDetails.GetProperty("audio_tokens").GetInt32() >= 0); + Assert.True(completionDetails.GetProperty("accepted_prediction_tokens").GetInt32() >= 0); + Assert.True(completionDetails.GetProperty("rejected_prediction_tokens").GetInt32() >= 0); + + // Assert - Optional fields + AssertJsonPropertyExists(response, "service_tier"); + var serviceTier = response.GetProperty("service_tier").GetString(); + Assert.NotNull(serviceTier); + Assert.True(serviceTier == "default" || serviceTier == "auto", $"service_tier should be 'default' or 'auto', got '{serviceTier}'"); + } + + [Fact] + public async Task StreamingRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("streaming/request.json"); + string expectedResponseSse = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Extract expected text from SSE chunks + var expectedChunks = ParseChatCompletionChunksFromSse(expectedResponseSse); + string expectedText = string.Concat(expectedChunks + .Where(c => c.GetProperty("choices")[0].GetProperty("delta").TryGetProperty("content", out var content)) + .Select(c => c.GetProperty("choices")[0].GetProperty("delta").GetProperty("content").GetString())); + + HttpClient client = await this.CreateTestServerAsync("streaming-agent", "You are a helpful assistant.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "streaming-agent", requestJson); + + // Assert - Response should be SSE format + Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); + + string responseSse = await httpResponse.Content.ReadAsStringAsync(); + var chunks = ParseChatCompletionChunksFromSse(responseSse); + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has stream flag + AssertJsonPropertyEquals(request, "stream", true); + + // Assert - Response has valid chunks + Assert.NotEmpty(chunks); + + // Assert - All chunks have same ID + string? firstId = null; + foreach (var chunk in chunks) + { + AssertJsonPropertyExists(chunk, "id"); + AssertJsonPropertyEquals(chunk, "object", "chat.completion.chunk"); + AssertJsonPropertyExists(chunk, "created"); + AssertJsonPropertyExists(chunk, "model"); + AssertJsonPropertyExists(chunk, "choices"); + + string chunkId = chunk.GetProperty("id").GetString()!; + Assert.StartsWith("chatcmpl-", chunkId); + + firstId ??= chunkId; + Assert.Equal(firstId, chunkId); + } + + // Assert - First chunk has role + var firstChunk = chunks[0]; + var firstChoice = firstChunk.GetProperty("choices")[0]; + AssertJsonPropertyExists(firstChoice, "delta"); + var firstDelta = firstChoice.GetProperty("delta"); + if (firstDelta.TryGetProperty("role", out var role)) + { + Assert.Equal("assistant", role.GetString()); + } + + // Assert - Content chunks have delta content + var contentChunks = chunks.Where(c => + c.GetProperty("choices")[0].GetProperty("delta").TryGetProperty("content", out _)).ToList(); + Assert.NotEmpty(contentChunks); + + // Assert - Last chunk has finish_reason + var lastChunk = chunks[^1]; + var lastChoice = lastChunk.GetProperty("choices")[0]; + if (lastChoice.TryGetProperty("finish_reason", out var finishReason) && finishReason.ValueKind != JsonValueKind.Null) + { + string reason = finishReason.GetString()!; + Assert.Contains(reason, collection: ["stop", "length", "tool_calls", "content_filter"]); + } + + // Assert - Last chunk may have usage + if (lastChunk.TryGetProperty("usage", out var usage)) + { + AssertJsonPropertyExists(usage, "prompt_tokens"); + AssertJsonPropertyExists(usage, "completion_tokens"); + AssertJsonPropertyExists(usage, "total_tokens"); + } + + // Assert - Accumulated content matches expected + string accumulatedText = string.Concat(contentChunks + .Select(c => c.GetProperty("choices")[0].GetProperty("delta").GetProperty("content").GetString())); + Assert.NotEmpty(accumulatedText); + } + + [Fact] + public async Task FunctionCallingRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("function_calling/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("function_calling/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + // Get expected function call details + const string FunctionName = "get_weather"; + + HttpClient client = await this.CreateTestServerAsync("function-agent", "You are a helpful assistant.", FunctionName, + (msg) => [new FunctionCallContent("call_abc123xyz", "get_weather", new Dictionary() { + { "location", "San Francisco, CA" }, + { "unit", "fahrenheit" } + })] + ); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "function-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has tools array + AssertJsonPropertyExists(request, "tools"); + var tools = request.GetProperty("tools"); + Assert.Equal(JsonValueKind.Array, tools.ValueKind); + Assert.True(tools.GetArrayLength() > 0); + + // Assert - Tool structure + var tool = tools[0]; + AssertJsonPropertyEquals(tool, "type", "function"); + AssertJsonPropertyExists(tool, "function"); + var function = tool.GetProperty("function"); + AssertJsonPropertyEquals(function, "name", "get_weather"); + AssertJsonPropertyExists(function, "description"); + AssertJsonPropertyExists(function, "parameters"); + + // Assert - Parameters have JSON Schema + var parameters = function.GetProperty("parameters"); + AssertJsonPropertyEquals(parameters, "type", "object"); + AssertJsonPropertyExists(parameters, "properties"); + AssertJsonPropertyExists(parameters, "required"); + + // Assert - Response has tool_calls. Not always will return that, so can default to "stop" + var choices = response.GetProperty("choices"); + var choice = choices[0]; + var message = choice.GetProperty("message"); + AssertJsonPropertyEquals(choice, "finish_reason", ["tool_calls", "stop"]); + AssertJsonPropertyExists(message, "tool_calls"); + + // Assert - Tool call structure + var toolCalls = message.GetProperty("tool_calls"); + Assert.Equal(JsonValueKind.Array, toolCalls.ValueKind); + Assert.True(toolCalls.GetArrayLength() > 0); + + var toolCall = toolCalls[0]; + AssertJsonPropertyExists(toolCall, "id"); + AssertJsonPropertyEquals(toolCall, "type", "function"); + AssertJsonPropertyExists(toolCall, "function"); + + var callFunction = toolCall.GetProperty("function"); + AssertJsonPropertyEquals(callFunction, "name", "get_weather"); + AssertJsonPropertyExists(callFunction, "arguments"); + + // Assert - Arguments are valid JSON + string arguments = callFunction.GetProperty("arguments").GetString()!; + using var argsDoc = JsonDocument.Parse(arguments); + var argsRoot = argsDoc.RootElement; + AssertJsonPropertyExists(argsRoot, "location"); + + // Assert - Message content is null when tool_calls present. Can be absent or null. + if (message.TryGetProperty("content", out var contentProp)) + { + Assert.Equal(JsonValueKind.Null, contentProp.ValueKind); + } + } + + [Fact] + public async Task SystemMessageRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("system_message/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("system_message/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("system-agent", "You are a helpful assistant that speaks like a pirate.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "system-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has messages with system role + var messages = request.GetProperty("messages"); + Assert.True(messages.GetArrayLength() >= 2); + + var systemMessage = messages[0]; + AssertJsonPropertyEquals(systemMessage, "role", "system"); + AssertJsonPropertyExists(systemMessage, "content"); + string systemContent = systemMessage.GetProperty("content").GetString()!; + Assert.Contains("pirate", systemContent, System.StringComparison.OrdinalIgnoreCase); + + var userMessage = messages[1]; + AssertJsonPropertyEquals(userMessage, "role", "user"); + + // Assert - Response reflects system message influence + var responseMessage = response.GetProperty("choices")[0].GetProperty("message"); + string content = responseMessage.GetProperty("content").GetString()!; + Assert.NotNull(content); + Assert.Equal(expectedText, content); + } + + [Fact] + public async Task MultiTurnConversationRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("multi_turn/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("multi_turn/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("multi-turn-agent", "You are a helpful assistant.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "multi-turn-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has conversation history + var messages = request.GetProperty("messages"); + Assert.True(messages.GetArrayLength() >= 3, "Should have at least 3 messages for multi-turn"); + + // Assert - Message sequence alternates between user and assistant + AssertJsonPropertyEquals(messages[0], "role", "user"); + AssertJsonPropertyEquals(messages[1], "role", "assistant"); + AssertJsonPropertyEquals(messages[2], "role", "user"); + + // Assert - Response continues conversation + var responseMessage = response.GetProperty("choices")[0].GetProperty("message"); + AssertJsonPropertyEquals(responseMessage, "role", "assistant"); + string content = responseMessage.GetProperty("content").GetString()!; + Assert.NotNull(content); + Assert.Equal(expectedText, content); + + // Assert - Usage tokens account for conversation history + var usage = response.GetProperty("usage"); + int promptTokens = usage.GetProperty("prompt_tokens").GetInt32(); + Assert.True(promptTokens > 20, "Prompt tokens should account for conversation history"); + } + + [Fact] + public async Task JsonModeRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("json_mode/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("json_mode/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("json-agent", "You are a helpful assistant that outputs JSON.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "json-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has response_format with json_schema + AssertJsonPropertyExists(request, "response_format"); + var responseFormat = request.GetProperty("response_format"); + AssertJsonPropertyEquals(responseFormat, "type", "json_schema"); + AssertJsonPropertyExists(responseFormat, "json_schema"); + + var jsonSchema = responseFormat.GetProperty("json_schema"); + AssertJsonPropertyEquals(jsonSchema, "name", "person_info"); + AssertJsonPropertyEquals(jsonSchema, "strict", true); + AssertJsonPropertyExists(jsonSchema, "schema"); + + var schema = jsonSchema.GetProperty("schema"); + AssertJsonPropertyEquals(schema, "type", "object"); + AssertJsonPropertyExists(schema, "properties"); + AssertJsonPropertyExists(schema, "required"); + + // Assert - Response content is valid JSON matching schema + var responseMessage = response.GetProperty("choices")[0].GetProperty("message"); + string content = responseMessage.GetProperty("content").GetString()!; + Assert.NotNull(content); + Assert.Equal(expectedText, content); + + using var jsonDoc = JsonDocument.Parse(content); + var jsonRoot = jsonDoc.RootElement; + AssertJsonPropertyExists(jsonRoot, "name"); + AssertJsonPropertyExists(jsonRoot, "age"); + AssertJsonPropertyExists(jsonRoot, "occupation"); + + Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("name").ValueKind); + Assert.Equal(JsonValueKind.Number, jsonRoot.GetProperty("age").ValueKind); + Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("occupation").ValueKind); + } + + /// + /// Helper to parse chat completion chunks from SSE response. + /// + private static List ParseChatCompletionChunksFromSse(string sseContent) + { + var chunks = new List(); + var lines = sseContent.Split('\n'); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].TrimEnd('\r'); + + if (line.StartsWith("data: ", System.StringComparison.Ordinal)) + { + var jsonData = line.Substring("data: ".Length); + + // Skip [DONE] marker + if (jsonData == "[DONE]") + { + continue; + } + + try + { + var doc = JsonDocument.Parse(jsonData); + chunks.Add(doc.RootElement.Clone()); + } + catch + { + // Skip invalid JSON + } + } + } + + return chunks; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs new file mode 100644 index 0000000000..6d02383ceb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs @@ -0,0 +1,974 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenAI; +using OpenAI.Chat; +using ChatFinishReason = OpenAI.Chat.ChatFinishReason; +using ChatMessage = OpenAI.Chat.ChatMessage; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Integration tests that start a web server and use the OpenAI Chat Completions SDK client to verify protocol compatibility. +/// These tests validate both streaming and non-streaming request scenarios. +/// +public sealed class OpenAIChatCompletionsIntegrationTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _httpClient; + + public async ValueTask DisposeAsync() + { + this._httpClient?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } + + /// + /// Verifies that streaming chat completions work correctly with the OpenAI SDK client. + /// + [Fact] + public async Task CreateChatCompletionStreaming_WithSimpleMessage_ReturnsStreamingUpdatesAsync() + { + // Arrange + const string AgentName = "streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "One Two Three"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Count to 3") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + if (update.ContentUpdate.Count > 0) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + } + + Assert.NotEmpty(updates); + + // Verify content was received + string content = contentBuilder.ToString(); + Assert.Equal(ExpectedResponse, content); + + // Verify finish reason + StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null); + Assert.NotNull(lastUpdate); + Assert.Equal(ChatFinishReason.Stop, lastUpdate.FinishReason); + } + + /// + /// Verifies that non-streaming chat completions work correctly with the OpenAI SDK client. + /// + [Fact] + public async Task CreateChatCompletion_WithSimpleMessage_ReturnsCompleteResponseAsync() + { + // Arrange + const string AgentName = "non-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Hello! How can I help you today?"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Hello") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.NotNull(completion.Id); + Assert.StartsWith("chatcmpl-", completion.Id); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + + // Verify content + string content = completion.Content[0].Text; + Assert.Equal(ExpectedResponse, content); + } + + /// + /// Verifies that streaming chat completions can handle multiple content chunks. + /// + [Fact] + public async Task CreateChatCompletionStreaming_WithMultipleChunks_StreamsAllContentAsync() + { + // Arrange + const string AgentName = "multi-chunk-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "This is a test response with multiple words"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + // Verify all content was received + string receivedContent = contentBuilder.ToString(); + Assert.Equal(ExpectedResponse, receivedContent); + + // Verify multiple content chunks were received + List contentUpdates = updates.Where(u => u.ContentUpdate.Count > 0).ToList(); + Assert.True(contentUpdates.Count > 1, "Expected multiple content chunks in streaming response"); + } + + /// + /// Verifies that multiple agents can be accessed via the same server. + /// + [Fact] + public async Task CreateChatCompletion_WithMultipleAgents_EachAgentRespondsCorrectlyAsync() + { + // Arrange + const string Agent1Name = "agent-one"; + const string Agent1Instructions = "You are agent one."; + const string Agent1Response = "Response from agent one"; + + const string Agent2Name = "agent-two"; + const string Agent2Instructions = "You are agent two."; + const string Agent2Response = "Response from agent two"; + + this._httpClient = await this.CreateTestServerWithMultipleAgentsAsync( + (Agent1Name, Agent1Instructions, Agent1Response), + (Agent2Name, Agent2Instructions, Agent2Response)); + + ChatClient chatClient1 = this.CreateChatClient(Agent1Name); + ChatClient chatClient2 = this.CreateChatClient(Agent2Name); + + List messages = + [ + new UserChatMessage("Hello") + ]; + + // Act + ChatCompletion completion1 = await chatClient1.CompleteChatAsync(messages); + ChatCompletion completion2 = await chatClient2.CompleteChatAsync(messages); + + // Assert + string content1 = completion1.Content[0].Text; + string content2 = completion2.Content[0].Text; + + Assert.Equal(Agent1Response, content1); + Assert.Equal(Agent2Response, content2); + Assert.NotEqual(content1, content2); + } + + /// + /// Verifies that streaming and non-streaming work correctly for the same agent. + /// + [Fact] + public async Task CreateChatCompletion_SameAgentStreamingAndNonStreaming_BothWorkCorrectlyAsync() + { + // Arrange + const string AgentName = "dual-mode-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "This is the response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act - Non-streaming + ChatCompletion nonStreamingCompletion = await chatClient.CompleteChatAsync(messages); + + // Act - Streaming + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + StringBuilder streamingContent = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + streamingContent.Append(contentPart.Text); + } + } + + // Assert + string nonStreamingContent = nonStreamingCompletion.Content[0].Text; + Assert.Equal(ExpectedResponse, nonStreamingContent); + Assert.Equal(ExpectedResponse, streamingContent.ToString()); + } + + /// + /// Verifies that the finish reason is correctly set for completed responses. + /// + [Fact] + public async Task CreateChatCompletion_CompletedResponse_HasCorrectFinishReasonAsync() + { + // Arrange + const string AgentName = "finish-reason-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Complete"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.NotNull(completion.Id); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + + /// + /// Verifies that streaming responses contain the expected chunk sequence. + /// + [Fact] + public async Task CreateChatCompletionStreaming_VerifyChunkSequence_ContainsExpectedDataAsync() + { + // Arrange + const string AgentName = "chunk-sequence-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Test response with multiple words"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + } + + // Verify chunks received + Assert.NotEmpty(updates); + + // First chunk should have role + StreamingChatCompletionUpdate? firstUpdate = updates.FirstOrDefault(u => u.Role != null); + if (firstUpdate != null) + { + Assert.Equal(ChatMessageRole.Assistant, firstUpdate.Role); + } + + // Should contain content chunks + List contentUpdates = updates.Where(u => u.ContentUpdate.Count > 0).ToList(); + Assert.NotEmpty(contentUpdates); + + // Last update should have finish reason + StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null); + Assert.NotNull(lastUpdate); + Assert.Equal(ChatFinishReason.Stop, lastUpdate.FinishReason); + } + + /// + /// Verifies that streaming responses properly handle empty responses. + /// + [Fact] + public async Task CreateChatCompletionStreaming_EmptyResponse_HandlesGracefullyAsync() + { + // Arrange + const string AgentName = "empty-response-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = ""; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + } + + // Should still receive chunks with finish reason + Assert.NotEmpty(updates); + Assert.Contains(updates, u => u.FinishReason == ChatFinishReason.Stop); + } + + /// + /// Verifies that non-streaming responses include proper metadata. + /// + [Fact] + public async Task CreateChatCompletion_IncludesMetadata_HasRequiredFieldsAsync() + { + // Arrange + const string AgentName = "metadata-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response with metadata"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion.Id); + Assert.StartsWith("chatcmpl-", completion.Id); + Assert.NotNull(completion.Model); + Assert.NotEqual(default, completion.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + } + + /// + /// Verifies that streaming responses handle very long text correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_LongText_StreamsAllContentAsync() + { + // Arrange + const string AgentName = "long-text-agent"; + const string Instructions = "You are a helpful assistant."; + string expectedResponse = string.Join(" ", Enumerable.Range(1, 100).Select(i => $"Word{i}")); + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, expectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Generate long text") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + string receivedContent = contentBuilder.ToString(); + Assert.Equal(expectedResponse, receivedContent); + } + + /// + /// Verifies that streaming responses properly handle single-word responses. + /// + [Fact] + public async Task CreateChatCompletionStreaming_SingleWord_StreamsCorrectlyAsync() + { + // Arrange + const string AgentName = "single-word-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Hello"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + Assert.Equal(ExpectedResponse, contentBuilder.ToString()); + } + + /// + /// Verifies that streaming responses preserve special characters and formatting. + /// + [Fact] + public async Task CreateChatCompletionStreaming_SpecialCharacters_PreservesFormattingAsync() + { + // Arrange + const string AgentName = "special-chars-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Hello! How are you? I'm fine. 100% great!"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + Assert.Equal(ExpectedResponse, contentBuilder.ToString()); + } + + /// + /// Verifies that non-streaming responses handle special characters correctly. + /// + [Fact] + public async Task CreateChatCompletion_SpecialCharacters_PreservesContentAsync() + { + // Arrange + const string AgentName = "special-chars-nonstreaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Symbols: @#$%^&*() Quotes: \"Hello\" 'World' Unicode: 你好 🌍"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + string content = completion.Content[0].Text; + Assert.Equal(ExpectedResponse, content); + } + + /// + /// Verifies that multiple sequential non-streaming requests work correctly. + /// + [Fact] + public async Task CreateChatCompletion_MultipleSequentialRequests_AllSucceedAsync() + { + // Arrange + const string AgentName = "sequential-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + // Act & Assert - Make 5 sequential requests + for (int i = 0; i < 5; i++) + { + List messages = + [ + new UserChatMessage($"Request {i}") + ]; + + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + } + + /// + /// Verifies that multiple sequential streaming requests work correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_MultipleSequentialRequests_AllStreamCorrectlyAsync() + { + // Arrange + const string AgentName = "sequential-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Streaming response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + // Act & Assert - Make 3 sequential streaming requests + for (int i = 0; i < 3; i++) + { + List messages = + [ + new UserChatMessage($"Request {i}") + ]; + + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + StringBuilder contentBuilder = new(); + + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + Assert.Equal(ExpectedResponse, contentBuilder.ToString()); + } + } + + /// + /// Verifies that completion IDs are unique across multiple requests. + /// + [Fact] + public async Task CreateChatCompletion_MultipleRequests_GenerateUniqueIdsAsync() + { + // Arrange + const string AgentName = "unique-id-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + // Act + List completionIds = []; + for (int i = 0; i < 10; i++) + { + List messages = + [ + new UserChatMessage($"Request {i}") + ]; + + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + completionIds.Add(completion.Id); + } + + // Assert + Assert.Equal(10, completionIds.Count); + Assert.Equal(completionIds.Count, completionIds.Distinct().Count()); // All IDs should be unique + } + + /// + /// Verifies that streaming responses all have the same ID within a single request. + /// + [Fact] + public async Task CreateChatCompletionStreaming_SameRequestId_ConsistentAcrossChunksAsync() + { + // Arrange + const string AgentName = "consistent-id-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Test consistent ID across chunks"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List chunkIds = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + if (!string.IsNullOrEmpty(update.CompletionId)) + { + chunkIds.Add(update.CompletionId); + } + } + + // All chunk IDs should be the same within a single request + Assert.NotEmpty(chunkIds); + Assert.All(chunkIds, id => Assert.Equal(chunkIds[0], id)); + Assert.StartsWith("chatcmpl-", chunkIds[0]); + } + + /// + /// Verifies that non-streaming responses work with system messages. + /// + [Fact] + public async Task CreateChatCompletion_WithSystemMessage_ReturnsValidResponseAsync() + { + // Arrange + const string AgentName = "system-message-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "I am following the system instructions"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new SystemChatMessage("You must respond in a specific way"), + new UserChatMessage("Hello") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + + /// + /// Verifies that responses handle newlines correctly. + /// + [Fact] + public async Task CreateChatCompletion_Newlines_PreservesFormattingAsync() + { + // Arrange + const string AgentName = "newline-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Line 1\nLine 2\nLine 3"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + string content = completion.Content[0].Text; + Assert.Equal(ExpectedResponse, content); + Assert.Contains("\n", content); + } + + /// + /// Verifies that streaming responses handle newlines correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_Newlines_PreservesFormattingAsync() + { + // Arrange + const string AgentName = "newline-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "First line\nSecond line\nThird line"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + string content = contentBuilder.ToString(); + Assert.Equal(ExpectedResponse, content); + Assert.Contains("\n", content); + } + + /// + /// Verifies that responses with conversation history work correctly. + /// + [Fact] + public async Task CreateChatCompletion_WithConversationHistory_ReturnsValidResponseAsync() + { + // Arrange + const string AgentName = "conversation-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "3 plus 3 equals 6"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("What is 2+2?"), + new AssistantChatMessage("2+2 equals 4"), + new UserChatMessage("What about 3+3?") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + + /// + /// Verifies that usage information is included in non-streaming responses. + /// + [Fact] + public async Task CreateChatCompletion_IncludesUsage_HasTokenCountsAsync() + { + // Arrange + const string AgentName = "usage-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response with usage information"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion.Usage); + Assert.True(completion.Usage.InputTokenCount > 0); + Assert.True(completion.Usage.OutputTokenCount > 0); + Assert.Equal(completion.Usage.InputTokenCount + completion.Usage.OutputTokenCount, completion.Usage.TotalTokenCount); + } + + /// + /// Verifies that responses with function calls work correctly. + /// + [Fact] + public async Task CreateChatCompletion_WithFunctionCall_ReturnsToolCallsAsync() + { + // Arrange + const string AgentName = "function-call-agent"; + const string Instructions = "You are a helpful assistant."; + const string FunctionName = "get_weather"; + const string Arguments = "{\"location\":\"Seattle\"}"; + + this._httpClient = await this.CreateTestServerWithCustomClientAsync( + agentName: AgentName, + instructions: Instructions, + chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments)); + + ChatClient openAIChatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("What's the weather?") + ]; + + // Act + ChatCompletion completion = await openAIChatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.ToolCalls, completion.FinishReason); + Assert.NotNull(completion.ToolCalls); + Assert.NotEmpty(completion.ToolCalls); + + ChatToolCall toolCall = completion.ToolCalls[0]; + Assert.Equal(FunctionName, toolCall.FunctionName); + Assert.NotNull(toolCall.FunctionArguments); + } + + /// + /// Verifies that streaming responses with function calls work correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_WithFunctionCall_StreamsToolCallsAsync() + { + // Arrange + const string AgentName = "function-call-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string FunctionName = "calculate"; + const string Arguments = "{\"expression\":\"2+2\"}"; + + this._httpClient = await this.CreateTestServerWithCustomClientAsync( + agentName: AgentName, + instructions: Instructions, + chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments)); + + ChatClient openAIChatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Calculate 2+2") + ]; + + // Act + AsyncCollectionResult streamingResult = openAIChatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + } + + Assert.NotEmpty(updates); + + // Should have finish reason of tool_calls + StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null); + Assert.NotNull(lastUpdate); + Assert.True(lastUpdate.FinishReason is ChatFinishReason.ToolCalls or ChatFinishReason.Stop); // depends on what response we get + } + + private ChatClient CreateChatClient(string agentName) + { + return new ChatClient( + model: "test-model", + credential: new ApiKeyCredential("test-api-key"), + options: new OpenAIClientOptions + { + Endpoint = new Uri(this._httpClient!.BaseAddress!, $"/{agentName}/v1/"), + Transport = new HttpClientPipelineTransport(this._httpClient) + }); + } + + private async Task CreateTestServerAsync(string agentName, string instructions, string responseText = "Test response") + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddOpenAIChatCompletions(); + builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client"); + + this._app = builder.Build(); + AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); + this._app.MapOpenAIChatCompletions(agent); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + + private async Task CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient); + builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}"); + builder.AddOpenAIChatCompletions(); + + this._app = builder.Build(); + AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); + this._app.MapOpenAIChatCompletions(agent); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + + private async Task CreateTestServerWithMultipleAgentsAsync( + params (string Name, string Instructions, string ResponseText)[] agents) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + foreach ((string name, string instructions, string responseText) in agents) + { + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText); + builder.Services.AddKeyedSingleton($"chat-client-{name}", mockChatClient); + builder.AddAIAgent(name, instructions, chatClientServiceKey: $"chat-client-{name}"); + } + + builder.AddOpenAIChatCompletions(); + + this._app = builder.Build(); + + foreach ((string name, string _, string _) in agents) + { + AIAgent agent = this._app.Services.GetRequiredKeyedService(name); + this._app.MapOpenAIChatCompletions(agent); + } + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs new file mode 100644 index 0000000000..edf11fbf6b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs @@ -0,0 +1,576 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Agents.AI.Hosting.OpenAI.Tests; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for OpenAI ChatCompletions API model serialization and deserialization. +/// These tests verify that our models correctly serialize to and deserialize from JSON +/// matching the OpenAI wire format, without testing actual API implementation behavior. +/// +public sealed class OpenAIChatCompletionsSerializationTests : ConformanceTestBase +{ + #region Request Deserialization Tests + + [Fact] + public void Deserialize_BasicRequest_Success() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.Equal("gpt-4o-mini", request.Model); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count > 0); + Assert.Equal(100, request.MaxCompletionTokens); + } + + [Fact] + public void Deserialize_BasicRequest_RoundTrip() + { + // Arrange + string originalJson = LoadChatCompletionsTraceFile("basic/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(originalJson, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + string reserializedJson = JsonSerializer.Serialize(request, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + CreateChatCompletion? roundtripped = JsonSerializer.Deserialize(reserializedJson, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(roundtripped); + Assert.Equal(request.Model, roundtripped.Model); + Assert.Equal(request.MaxCompletionTokens, roundtripped.MaxCompletionTokens); + Assert.Equal(request.Messages.Count, roundtripped.Messages.Count); + } + + [Fact] + public void Deserialize_BasicRequest_HasMessages() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.Single(request.Messages); + + var message = request.Messages[0]; + Assert.Equal("user", message.Role); + Assert.NotNull(message.Content); + } + + [Fact] + public void Deserialize_StreamingRequest_HasStreamFlag() + { + // Arrange + string json = LoadChatCompletionsTraceFile("streaming/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.True(request.Stream); + Assert.Equal(150, request.MaxCompletionTokens); + } + + [Fact] + public void Deserialize_SystemMessageRequest_HasSystemRole() + { + // Arrange + string json = LoadChatCompletionsTraceFile("system_message/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count >= 2); + Assert.Equal("system", request.Messages[0].Role); + Assert.Equal("user", request.Messages[1].Role); + } + + [Fact] + public void Deserialize_MultiTurnRequest_HasMultipleMessages() + { + // Arrange + string json = LoadChatCompletionsTraceFile("multi_turn/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count >= 3); + Assert.Equal("user", request.Messages[0].Role); + Assert.Equal("assistant", request.Messages[1].Role); + Assert.Equal("user", request.Messages[2].Role); + } + + [Fact] + public void Deserialize_FunctionCallingRequest_HasTools() + { + // Arrange + string json = LoadChatCompletionsTraceFile("function_calling/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Tools); + Assert.Single(request.Tools); + Assert.NotNull(request.ToolChoice?.Mode); + Assert.Equal("auto", request.ToolChoice.Mode); + } + + [Fact] + public void Deserialize_JsonModeRequest_HasResponseFormat() + { + // Arrange + string json = LoadChatCompletionsTraceFile("json_mode/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.ResponseFormat); + } + + [Fact] + public void Deserialize_AllRequests_CanBeDeserialized() + { + // Arrange + string[] requestPaths = + [ + "basic/request.json", + "streaming/request.json", + "system_message/request.json", + "multi_turn/request.json", + "function_calling/request.json", + "json_mode/request.json" + ]; + + foreach (var path in requestPaths) + { + string json = LoadChatCompletionsTraceFile(path); + + // Act & Assert - Should not throw + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count > 0, $"Request from {path} should have messages"); + } + } + + #endregion + + #region Response Deserialization Tests + + [Fact] + public void Deserialize_BasicResponse_Success() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.StartsWith("chatcmpl-", response.Id); + Assert.Equal("chat.completion", response.Object); + Assert.True(response.Created > 0); + Assert.NotNull(response.Model); + Assert.StartsWith("gpt-4o-mini", response.Model); + } + + [Fact] + public void Deserialize_BasicResponse_HasChoices() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + Assert.Single(response.Choices); + + var choice = response.Choices[0]; + Assert.Equal(0, choice.Index); + Assert.NotNull(choice.Message); + Assert.Equal("assistant", choice.Message.Role); + Assert.NotNull(choice.Message.Content); + Assert.NotNull(choice.FinishReason); + } + + [Fact] + public void Deserialize_BasicResponse_HasUsage() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Usage); + Assert.True(response.Usage.PromptTokens > 0); + Assert.True(response.Usage.CompletionTokens > 0); + Assert.Equal(response.Usage.PromptTokens + response.Usage.CompletionTokens, response.Usage.TotalTokens); + Assert.NotNull(response.Usage.PromptTokensDetails); + Assert.NotNull(response.Usage.CompletionTokensDetails); + } + + [Fact] + public void Deserialize_SystemMessageResponse_HasContent() + { + // Arrange + string json = LoadChatCompletionsTraceFile("system_message/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + var message = response.Choices[0].Message; + Assert.Equal("assistant", message.Role); + Assert.NotNull(message.Content); + Assert.Contains("Ahoy, matey", message.Content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Deserialize_MultiTurnResponse_HasContent() + { + // Arrange + string json = LoadChatCompletionsTraceFile("multi_turn/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + var message = response.Choices[0].Message; + Assert.Equal("assistant", message.Role); + Assert.NotNull(message.Content); + } + + [Fact] + public void Deserialize_FunctionCallingResponse_HasToolCalls() + { + // Arrange + string json = LoadChatCompletionsTraceFile("function_calling/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + + var choice = response.Choices[0]; + Assert.Equal("tool_calls", choice.FinishReason); + + var message = choice.Message; + Assert.NotNull(message.ToolCalls); + Assert.Single(message.ToolCalls); + + var toolCall = message.ToolCalls[0]; + Assert.NotNull(toolCall.Id); + Assert.StartsWith("call_", toolCall.Id); + Assert.Equal("function", toolCall.Type); + Assert.NotNull(toolCall.Function); + Assert.Equal("get_weather", toolCall.Function.Name); + Assert.NotNull(toolCall.Function.Arguments); + } + + [Fact] + public void Deserialize_JsonModeResponse_HasStructuredOutput() + { + // Arrange + string json = LoadChatCompletionsTraceFile("json_mode/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + + var message = response.Choices[0].Message; + Assert.NotNull(message.Content); + + // Verify the content is valid JSON + using var jsonDoc = JsonDocument.Parse(message.Content); + var jsonRoot = jsonDoc.RootElement; + Assert.Equal(JsonValueKind.Object, jsonRoot.ValueKind); + Assert.True(jsonRoot.TryGetProperty("name", out _)); + Assert.True(jsonRoot.TryGetProperty("age", out _)); + Assert.True(jsonRoot.TryGetProperty("occupation", out _)); + } + + [Fact] + public void Deserialize_AllResponses_HaveRequiredFields() + { + // Arrange + string[] responsePaths = + [ + "basic/response.json", + "system_message/response.json", + "multi_turn/response.json", + "function_calling/response.json", + "json_mode/response.json" + ]; + + foreach (var path in responsePaths) + { + string json = LoadChatCompletionsTraceFile(path); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Id); + Assert.Equal("chat.completion", response.Object); + Assert.True(response.Created > 0, $"Response from {path} should have created timestamp"); + Assert.NotNull(response.Model); + Assert.NotNull(response.Choices); + Assert.True(response.Choices.Count > 0, $"Response from {path} should have choices"); + } + } + + [Fact] + public void Deserialize_ResponseRoundTrip_PreservesData() + { + // Arrange + string originalJson = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act - Deserialize and re-serialize + ChatCompletion? response = JsonSerializer.Deserialize(originalJson, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + string reserializedJson = JsonSerializer.Serialize(response, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + ChatCompletion? roundtripped = JsonSerializer.Deserialize(reserializedJson, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(roundtripped); + Assert.Equal(response.Id, roundtripped.Id); + Assert.Equal(response.Created, roundtripped.Created); + Assert.Equal(response.Model, roundtripped.Model); + Assert.Equal(response.Choices.Count, roundtripped.Choices.Count); + } + + #endregion + + #region Streaming Chunk Deserialization Tests + + [Fact] + public void ParseStreamingChunks_BasicFormat_Success() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + + // Assert + Assert.NotEmpty(chunks); + Assert.All(chunks, chunk => + { + ChatCompletionChunk? parsed = JsonSerializer.Deserialize(chunk.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + Assert.NotNull(parsed); + Assert.NotNull(parsed.Id); + Assert.Equal("chat.completion.chunk", parsed.Object); + Assert.True(parsed.Created > 0); + Assert.NotNull(parsed.Model); + Assert.NotNull(parsed.Choices); + }); + } + + [Fact] + public void ParseStreamingChunks_AllChunksSameId() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + + // Deserialize chunks + var parsedChunks = chunks + .Select(c => JsonSerializer.Deserialize(c.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk)) + .Where(c => c != null) + .ToList(); + + // Assert + Assert.NotEmpty(parsedChunks); + + string? firstId = parsedChunks[0]!.Id; + Assert.NotNull(firstId); + Assert.StartsWith("chatcmpl-", firstId); + + Assert.All(parsedChunks, chunk => Assert.Equal(firstId, chunk!.Id)); + } + + [Fact] + public void ParseStreamingChunks_FirstChunkHasRole() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + var firstChunk = JsonSerializer.Deserialize(chunks[0].GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + + // Assert + Assert.NotNull(firstChunk); + Assert.NotNull(firstChunk.Choices); + Assert.True(firstChunk.Choices.Count > 0); + + var firstChoice = firstChunk.Choices[0]; + Assert.NotNull(firstChoice.Delta); + + if (firstChoice.Delta.Role != null) + { + Assert.Equal("assistant", firstChoice.Delta.Role); + } + } + + [Fact] + public void ParseStreamingChunks_AccumulateContent_MatchesExpected() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + var contentPieces = new List(); + + foreach (var chunkJson in chunks) + { + var chunk = JsonSerializer.Deserialize(chunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + if (chunk?.Choices != null && chunk.Choices.Count > 0) + { + var delta = chunk.Choices[0].Delta; + if (!string.IsNullOrEmpty(delta?.Content)) + { + contentPieces.Add(delta.Content); + } + } + } + + // Assert + Assert.NotEmpty(contentPieces); + string fullText = string.Concat(contentPieces); + Assert.NotEmpty(fullText); + Assert.Contains("circuits", fullText); + Assert.Contains("flight", fullText); + } + + [Fact] + public void ParseStreamingChunks_LastChunkHasFinishReason() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + + // Find chunks with finish_reason + var chunksWithFinishReason = new List(); + foreach (var chunkJson in chunks) + { + var chunk = JsonSerializer.Deserialize(chunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + if (chunk?.Choices != null && chunk.Choices.Count > 0 && !string.IsNullOrEmpty(chunk.Choices[0].FinishReason)) + { + chunksWithFinishReason.Add(chunk); + } + } + + // Assert + Assert.NotEmpty(chunksWithFinishReason); + var lastChunk = chunksWithFinishReason.Last(); + Assert.Contains(lastChunk.Choices[0].FinishReason, collection: ["stop", "length", "tool_calls", "content_filter"]); + } + + [Fact] + public void ParseStreamingChunks_LastChunkHasUsage() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + var lastChunkJson = chunks.Last(); + var lastChunk = JsonSerializer.Deserialize(lastChunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + + // Assert + Assert.NotNull(lastChunk); + Assert.NotNull(lastChunk.Usage); + Assert.True(lastChunk.Usage.PromptTokens > 0); + Assert.True(lastChunk.Usage.CompletionTokens > 0); + Assert.Equal(lastChunk.Usage.PromptTokens + lastChunk.Usage.CompletionTokens, lastChunk.Usage.TotalTokens); + } + + /// + /// Helper to parse chat completion chunks from SSE response. + /// + private static List ParseChatCompletionChunksFromSse(string sseContent) + { + var chunks = new List(); + var lines = sseContent.Split('\n'); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].TrimEnd('\r'); + + if (line.StartsWith("data: ", StringComparison.Ordinal)) + { + var jsonData = line.Substring("data: ".Length); + + // Skip [DONE] marker + if (jsonData == "[DONE]") + { + continue; + } + + try + { + var doc = JsonDocument.Parse(jsonData); + chunks.Add(doc.RootElement.Clone()); + } + catch + { + // Skip invalid JSON + } + } + } + + return chunks; + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs index de05ea666d..547920c0fa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs @@ -22,8 +22,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task BasicRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("basic/request.json"); - using var expectedResponseDoc = LoadTraceDocument("basic/response.json"); + string requestJson = LoadResponsesTraceFile("basic/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("basic/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get the expected response text from the trace to use as mock response @@ -34,7 +34,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("basic-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "basic-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "basic-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -160,8 +160,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ConversationRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("conversation/request.json"); - using var expectedResponseDoc = LoadTraceDocument("conversation/response.json"); + string requestJson = LoadResponsesTraceFile("conversation/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("conversation/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get the expected response text @@ -172,7 +172,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("conversation-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "conversation-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "conversation-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -270,8 +270,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ToolCallRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("tool_call/request.json"); - using var expectedResponseDoc = LoadTraceDocument("tool_call/response.json"); + string requestJson = LoadResponsesTraceFile("tool_call/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("tool_call/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get function call details from expected response @@ -282,7 +282,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("tool-agent", "You are a helpful assistant.", functionName); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "tool-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "tool-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -429,8 +429,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task StreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedResponseSse = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -440,7 +440,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -634,8 +634,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task MetadataRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("metadata/request.json"); - using var expectedResponseDoc = LoadTraceDocument("metadata/response.json"); + string requestJson = LoadResponsesTraceFile("metadata/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("metadata/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected text (truncated due to max_output_tokens) @@ -646,7 +646,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("metadata-agent", "Respond in a friendly, educational tone.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "metadata-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "metadata-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -761,8 +761,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ReasoningRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("reasoning/request.json"); - using var expectedResponseDoc = LoadTraceDocument("reasoning/response.json"); + string requestJson = LoadResponsesTraceFile("reasoning/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("reasoning/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected text from the message output @@ -773,7 +773,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("reasoning-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "reasoning-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "reasoning-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -843,8 +843,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task JsonOutputRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("json_output/request.json"); - using var expectedResponseDoc = LoadTraceDocument("json_output/response.json"); + string requestJson = LoadResponsesTraceFile("json_output/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("json_output/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected JSON text from response @@ -855,7 +855,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("json-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "json-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "json-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -927,8 +927,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task RefusalRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("refusal/request.json"); - using var expectedResponseDoc = LoadTraceDocument("refusal/response.json"); + string requestJson = LoadResponsesTraceFile("refusal/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("refusal/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected refusal text @@ -939,7 +939,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("refusal-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "refusal-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "refusal-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -986,8 +986,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ImageInputRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("image_input/request.json"); - using var expectedResponseDoc = LoadTraceDocument("image_input/response.json"); + string requestJson = LoadResponsesTraceFile("image_input/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("image_input/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected text @@ -998,7 +998,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("image-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "image-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "image-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -1059,8 +1059,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ReasoningStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("reasoning_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("reasoning_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("reasoning_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("reasoning_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1070,7 +1070,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("reasoning-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "reasoning-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "reasoning-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -1137,8 +1137,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task JsonOutputStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("json_output_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("json_output_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("json_output_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("json_output_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1148,7 +1148,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("json-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "json-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "json-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -1197,8 +1197,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task RefusalStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("refusal_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("refusal_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("refusal_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("refusal_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1208,7 +1208,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("refusal-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "refusal-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "refusal-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -1254,8 +1254,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ImageInputStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("image_input_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("image_input_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("image_input_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("image_input_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1265,7 +1265,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("image-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "image-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "image-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs index d487450248..08823e4494 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs @@ -22,7 +22,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicRequest_Success() { // Arrange - string json = LoadTraceFile("basic/request.json"); + string json = LoadResponsesTraceFile("basic/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -38,7 +38,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicRequest_RoundTrip() { // Arrange - string originalJson = LoadTraceFile("basic/request.json"); + string originalJson = LoadResponsesTraceFile("basic/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(originalJson, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -56,7 +56,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_StreamingRequest_HasStreamFlag() { // Arrange - string json = LoadTraceFile("streaming/request.json"); + string json = LoadResponsesTraceFile("streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -71,7 +71,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ConversationRequest_HasPreviousResponseId() { // Arrange - string json = LoadTraceFile("conversation/request.json"); + string json = LoadResponsesTraceFile("conversation/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -86,7 +86,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataRequest_HasAllParameters() { // Arrange - string json = LoadTraceFile("metadata/request.json"); + string json = LoadResponsesTraceFile("metadata/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -111,7 +111,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ToolCallRequest_HasToolDefinitions() { // Arrange - string json = LoadTraceFile("tool_call/request.json"); + string json = LoadResponsesTraceFile("tool_call/request.json"); // Act // CreateResponse doesn't have Tools property - it uses dynamic JSON @@ -220,7 +220,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ImageInputRequest_HasImageData() { // Arrange - string json = LoadTraceFile("image_input/request.json"); + string json = LoadResponsesTraceFile("image_input/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -234,7 +234,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ImageInputStreamingRequest_HasStreamAndImage() { // Arrange - string json = LoadTraceFile("image_input_streaming/request.json"); + string json = LoadResponsesTraceFile("image_input_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -249,7 +249,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_JsonOutputRequest_HasJsonSchema() { // Arrange - string json = LoadTraceFile("json_output/request.json"); + string json = LoadResponsesTraceFile("json_output/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -270,7 +270,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_JsonOutputStreamingRequest_HasJsonSchemaAndStream() { // Arrange - string json = LoadTraceFile("json_output_streaming/request.json"); + string json = LoadResponsesTraceFile("json_output_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -290,7 +290,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ReasoningRequest_HasReasoningConfiguration() { // Arrange - string json = LoadTraceFile("reasoning/request.json"); + string json = LoadResponsesTraceFile("reasoning/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -304,7 +304,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ReasoningStreamingRequest_HasReasoningAndStream() { // Arrange - string json = LoadTraceFile("reasoning_streaming/request.json"); + string json = LoadResponsesTraceFile("reasoning_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -319,7 +319,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_RefusalRequest_CanBeDeserialized() { // Arrange - string json = LoadTraceFile("refusal/request.json"); + string json = LoadResponsesTraceFile("refusal/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -333,7 +333,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_RefusalStreamingRequest_HasStream() { // Arrange - string json = LoadTraceFile("refusal_streaming/request.json"); + string json = LoadResponsesTraceFile("refusal_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -367,7 +367,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase foreach (var path in requestPaths) { - string json = LoadTraceFile(path); + string json = LoadResponsesTraceFile(path); // Act & Assert - Should not throw CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -384,7 +384,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicResponse_Success() { // Arrange - string json = LoadTraceFile("basic/response.json"); + string json = LoadResponsesTraceFile("basic/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -403,7 +403,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicResponse_HasCorrectOutput() { // Arrange - string json = LoadTraceFile("basic/response.json"); + string json = LoadResponsesTraceFile("basic/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -426,7 +426,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicResponse_HasCorrectUsage() { // Arrange - string json = LoadTraceFile("basic/response.json"); + string json = LoadResponsesTraceFile("basic/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -445,7 +445,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ConversationResponse_HasPreviousResponseId() { // Arrange - string json = LoadTraceFile("conversation/response.json"); + string json = LoadResponsesTraceFile("conversation/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -461,7 +461,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_PreservesMetadata() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -478,7 +478,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_HasIncompleteStatus() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -494,7 +494,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_HasInstructions() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -509,7 +509,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_HasModelParameters() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -525,7 +525,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ToolCallResponse_HasFunctionCall() { // Arrange - string json = LoadTraceFile("tool_call/response.json"); + string json = LoadResponsesTraceFile("tool_call/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -549,7 +549,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ToolCallResponse_HasToolDefinitions() { // Arrange - string json = LoadTraceFile("tool_call/response.json"); + string json = LoadResponsesTraceFile("tool_call/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -573,7 +573,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ImageInputResponse_HasImageInInput() { // Arrange - string json = LoadTraceFile("image_input/response.json"); + string json = LoadResponsesTraceFile("image_input/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -588,7 +588,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_JsonOutputResponse_HasStructuredOutput() { // Arrange - string json = LoadTraceFile("json_output/response.json"); + string json = LoadResponsesTraceFile("json_output/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -608,7 +608,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ReasoningResponse_HasReasoningItems() { // Arrange - string json = LoadTraceFile("reasoning/response.json"); + string json = LoadResponsesTraceFile("reasoning/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -624,7 +624,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_RefusalResponse_HasRefusalContent() { // Arrange - string json = LoadTraceFile("refusal/response.json"); + string json = LoadResponsesTraceFile("refusal/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -653,7 +653,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase foreach (var path in responsePaths) { - string json = LoadTraceFile(path); + string json = LoadResponsesTraceFile(path); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -672,7 +672,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ResponseRoundTrip_PreservesData() { // Arrange - string originalJson = LoadTraceFile("basic/response.json"); + string originalJson = LoadResponsesTraceFile("basic/response.json"); // Act - Deserialize and re-serialize Response? response = JsonSerializer.Deserialize(originalJson, Responses.ResponsesJsonContext.Default.Response); @@ -696,7 +696,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_BasicFormat_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -715,7 +715,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_HasCorrectEventTypes() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -736,7 +736,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeCreatedEvent_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var createdEventJson = events.First(e => e.GetProperty("type").GetString() == "response.created"); @@ -758,7 +758,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeInProgressEvent_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var inProgressEventJson = events.First(e => e.GetProperty("type").GetString() == "response.in_progress"); @@ -779,7 +779,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeOutputItemAdded_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var itemAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.output_item.added"); @@ -799,7 +799,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeContentPartAdded_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var partAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.content_part.added"); @@ -821,7 +821,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeTextDelta_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var textDeltaJson = events.First(e => e.GetProperty("type").GetString() == "response.output_text.delta"); @@ -843,7 +843,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_AccumulateTextDeltas_MatchesFinalText() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); // Act @@ -877,7 +877,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_SequenceNumbersAreSequential() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); // Act @@ -904,7 +904,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_FinalEvent_IsTerminalState() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var lastEventJson = events.Last(); @@ -926,7 +926,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_ImageInputStreaming_HasImageEvents() { // Arrange - string sseContent = LoadTraceFile("image_input_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("image_input_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -944,7 +944,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_JsonOutputStreaming_HasJsonSchemaEvents() { // Arrange - string sseContent = LoadTraceFile("json_output_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("json_output_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -962,7 +962,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_ReasoningStreaming_HasReasoningEvents() { // Arrange - string sseContent = LoadTraceFile("reasoning_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("reasoning_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -983,7 +983,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_RefusalStreaming_HasRefusalEvents() { // Arrange - string sseContent = LoadTraceFile("refusal_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("refusal_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -1014,7 +1014,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase foreach (var path in streamingPaths) { - string sseContent = LoadTraceFile(path); + string sseContent = LoadResponsesTraceFile(path); // Act & Assert foreach (var eventJson in ParseSseEventsFromContent(sseContent)) @@ -1030,7 +1030,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_AllEvents_CanBeDeserialized() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); // Act & Assert foreach (var eventJson in ParseSseEventsFromContent(sseContent)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs index 22e3838a67..573b3ad26a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs @@ -24,8 +24,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_BasicFormat_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); // Extract expected text var expectedEvents = ParseSseEvents(expectedSseContent); @@ -35,7 +35,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-basic-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-basic-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-basic-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); // Act @@ -55,8 +55,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_HasCorrectEventTypesAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -65,7 +65,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-types-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-types-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-types-agent", requestJson); // Assert - HTTP response validation Assert.Equal(System.Net.HttpStatusCode.OK, httpResponse.StatusCode); @@ -118,8 +118,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeCreatedEvent_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -128,7 +128,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-created-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-created-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-created-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var createdEventJson = events.First(e => e.GetProperty("type").GetString() == "response.created"); @@ -151,8 +151,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeInProgressEvent_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -161,7 +161,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-progress-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-progress-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-progress-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var inProgressEventJson = events.First(e => e.GetProperty("type").GetString() == "response.in_progress"); @@ -183,8 +183,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeOutputItemAdded_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -193,7 +193,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-item-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-item-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-item-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var itemAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.output_item.added"); @@ -214,8 +214,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeContentPartAdded_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -224,7 +224,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-part-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-part-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-part-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var partAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.content_part.added"); @@ -247,8 +247,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeTextDelta_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -257,7 +257,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-delta-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-delta-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-delta-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var textDeltaJson = events.First(e => e.GetProperty("type").GetString() == "response.output_text.delta"); @@ -280,8 +280,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_AccumulateTextDeltas_MatchesFinalTextAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -290,7 +290,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-accumulate-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-accumulate-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-accumulate-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -325,8 +325,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_SequenceNumbersAreSequentialAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -335,7 +335,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-sequence-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-sequence-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-sequence-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -363,8 +363,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_FinalEvent_IsTerminalStateAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -373,7 +373,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-terminal-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-terminal-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-terminal-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var lastEventJson = events.Last(); @@ -396,8 +396,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_AllEvents_CanBeDeserializedAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -406,7 +406,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-deserialize-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-deserialize-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-deserialize-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); // Act & Assert @@ -439,8 +439,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_IdConsistency_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -449,7 +449,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-id-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-id-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-id-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -530,8 +530,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_IndexConsistency_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -540,7 +540,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-index-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-index-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-index-agent", requestJson); // Assert - All events with output_index should have valid values foreach (var eventJson in ParseSseEvents(await httpResponse.Content.ReadAsStringAsync())) @@ -587,8 +587,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_ResponseObjectEvolution_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -597,7 +597,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-evolution-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-evolution-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-evolution-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -655,8 +655,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_SseFormatCompliance_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -665,7 +665,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-sse-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-sse-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-sse-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); // Assert - SSE format validation @@ -699,8 +699,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_EventPairing_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -709,7 +709,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-pairing-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-pairing-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-pairing-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -755,8 +755,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_NoDuplicateSequenceNumbers_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -765,7 +765,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-nodup-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-nodup-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-nodup-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); From 5e38c634553aa8a48b9d4635c68a329e0766f57c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:00:18 +0000 Subject: [PATCH 14/16] .NET: [BREAKING] Simplify TextSearchProvider construction and improve Mem0Provider scoping. (#1905) * Simplify TextSearchProvider construction and improve Mem0Provider scoping * Fixing indentation. --- .../Catalog/AgentWithTextSearchRag/Program.cs | 2 +- .../Program.cs | 4 +- .../Program.cs | 4 +- .../Agent_Step18_TextSearchRag/Program.cs | 4 +- .../Agent_Step19_Mem0Provider/Program.cs | 4 +- .../Microsoft.Agents.AI.Mem0/Mem0Provider.cs | 154 +++++++++--------- .../Mem0ProviderOptions.cs | 27 --- .../Mem0ProviderScope.cs | 57 +++++++ .../Data/TextSearchProvider.cs | 96 +++++------ .../Mem0ProviderTests.cs | 12 +- .../Mem0ProviderTests.cs | 144 ++++++---------- .../Data/TextSearchProviderTests.cs | 30 ++-- 12 files changed, 251 insertions(+), 287 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderScope.cs diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs b/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs index 3e86534edd..65f3a9e98f 100644 --- a/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs +++ b/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs @@ -28,7 +28,7 @@ AIAgent agent = new AzureOpenAIClient( .CreateAIAgent(new ChatClientAgentOptions { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", - AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions) + AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) }); AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs index 611e11c22c..ec665325a7 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs @@ -63,9 +63,7 @@ AIAgent agent = azureOpenAIClient .CreateAIAgent(new ChatClientAgentOptions { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", - AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined - ? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) - : new TextSearchProvider(SearchAdapter, textSearchOptions) + AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) }); AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs index e29bb58d04..4e8fbf0bde 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs @@ -72,9 +72,7 @@ AIAgent agent = azureOpenAIClient .CreateAIAgent(new ChatClientAgentOptions { Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.", - AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined - ? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) - : new TextSearchProvider(SearchAdapter, textSearchOptions) + AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) }); AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs index 81a6b29152..65f3a9e98f 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs @@ -28,9 +28,7 @@ AIAgent agent = new AzureOpenAIClient( .CreateAIAgent(new ChatClientAgentOptions { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", - AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined - ? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) - : new TextSearchProvider(MockSearchAsync, textSearchOptions) + AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) }); AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Program.cs index 21aa9d3e1d..539ebbaecb 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Program.cs @@ -33,9 +33,9 @@ AIAgent agent = new AzureOpenAIClient( Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details.", AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null or JsonValueKind.Undefined // If each thread should have its own Mem0 scope, you can create a new id per thread here: - // ? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ThreadId = Guid.NewGuid().ToString() }) + // ? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() }) // In this case we are storing memories scoped by application and user instead so that memories are retained across threads. - ? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ApplicationId = "getting-started-agents", UserId = "sample-user" }) + ? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" }) // For cases where we are restoring from serialized state: : new Mem0Provider(mem0HttpClient, ctx.SerializedState, ctx.JsonSerializerOptions) }); diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index d0f6f78b3f..4aae5de59b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -31,10 +32,15 @@ public sealed class Mem0Provider : AIContextProvider private readonly Mem0Client _client; private readonly ILogger? _logger; + private readonly Mem0ProviderScope _storageScope; + private readonly Mem0ProviderScope _searchScope; + /// /// Initializes a new instance of the class. /// /// Configured (base address + auth). + /// Optional values to scope the memory storage with. + /// Optional values to scope the memory search with. Defaults to if not provided. /// Provider options. /// Optional logger factory. /// @@ -47,21 +53,35 @@ public sealed class Mem0Provider : AIContextProvider /// new Mem0AIContextProvider(httpClient); /// /// - public Mem0Provider(HttpClient httpClient, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + public Mem0Provider(HttpClient httpClient, Mem0ProviderScope storageScope, Mem0ProviderScope? searchScope = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null) { if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri)) { throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient)); } - this.ApplicationId = options?.ApplicationId; - this.AgentId = options?.AgentId; - this.ThreadId = options?.ThreadId; - this.UserId = options?.UserId; - this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; - this._logger = loggerFactory?.CreateLogger(); this._client = new Mem0Client(httpClient); + + this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; + this._storageScope = new Mem0ProviderScope(Throw.IfNull(storageScope)); + this._searchScope = searchScope ?? storageScope; + + if (string.IsNullOrWhiteSpace(this._storageScope.ApplicationId) + && string.IsNullOrWhiteSpace(this._storageScope.AgentId) + && string.IsNullOrWhiteSpace(this._storageScope.ThreadId) + && string.IsNullOrWhiteSpace(this._storageScope.UserId)) + { + throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope."); + } + + if (string.IsNullOrWhiteSpace(this._searchScope.ApplicationId) + && string.IsNullOrWhiteSpace(this._searchScope.AgentId) + && string.IsNullOrWhiteSpace(this._searchScope.ThreadId) + && string.IsNullOrWhiteSpace(this._searchScope.UserId)) + { + throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope."); + } } /// @@ -70,6 +90,7 @@ public sealed class Mem0Provider : AIContextProvider /// Configured (base address + auth). /// A representing the serialized state of the store. /// Optional settings for customizing the JSON deserialization process. + /// Provider options. /// Optional logger factory. /// /// @@ -82,46 +103,30 @@ public sealed class Mem0Provider : AIContextProvider /// new Mem0AIContextProvider(httpClient, state); /// /// - public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, ILoggerFactory? loggerFactory = null) + public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null) { if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri)) { throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient)); } + this._logger = loggerFactory?.CreateLogger(); + this._client = new Mem0Client(httpClient); + + this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; + var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions; var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(Mem0State))) as Mem0State; - this.ApplicationId = state?.ApplicationId; - this.AgentId = state?.AgentId; - this.ThreadId = state?.ThreadId; - this.UserId = state?.UserId; - this._contextPrompt = state?.ContextPrompt ?? DefaultContextPrompt; + if (state == null || state.StorageScope == null || state.SearchScope == null) + { + throw new InvalidOperationException("The Mem0Provider state did not contain the required scope properties."); + } - this._logger = loggerFactory?.CreateLogger(); - this._client = new Mem0Client(httpClient); + this._storageScope = state.StorageScope; + this._searchScope = state.SearchScope; } - /// - /// Gets or sets an optional ID for the application to scope memories to. - /// - public string? ApplicationId { get; set; } - - /// - /// Gets or sets an optional ID for the agent to scope memories to. - /// - public string? AgentId { get; set; } - - /// - /// Gets or sets an optional ID for the thread to scope memories to. - /// - public string? ThreadId { get; set; } - - /// - /// Gets or sets an optional ID for the user to scope memories to. - /// - public string? UserId { get; set; } - /// public override async ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) { @@ -134,10 +139,10 @@ public sealed class Mem0Provider : AIContextProvider try { var memories = (await this._client.SearchAsync( - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId, + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId, queryText, cancellationToken).ConfigureAwait(false)).ToList(); @@ -150,20 +155,20 @@ public sealed class Mem0Provider : AIContextProvider this._logger.LogInformation( "Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", memories.Count, - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId); + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); if (outputMessageText is not null) { this._logger.LogTrace( "Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", queryText, outputMessageText, - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId); + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); } } @@ -181,10 +186,10 @@ public sealed class Mem0Provider : AIContextProvider this._logger?.LogError( ex, "Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId); + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); return new AIContext(); } } @@ -207,10 +212,10 @@ public sealed class Mem0Provider : AIContextProvider this._logger?.LogError( ex, "Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId); + this._storageScope.ApplicationId, + this._storageScope.AgentId, + this._storageScope.ThreadId, + this._storageScope.UserId); } } @@ -220,23 +225,16 @@ public sealed class Mem0Provider : AIContextProvider /// Cancellation token. public Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) => this._client.ClearMemoryAsync( - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId, + this._storageScope.ApplicationId, + this._storageScope.AgentId, + this._storageScope.ThreadId, + this._storageScope.UserId, cancellationToken); /// public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) { - var state = new Mem0State - { - ApplicationId = this.ApplicationId, - AgentId = this.AgentId, - ThreadId = this.ThreadId, - UserId = this.UserId, - ContextPrompt = this._contextPrompt == DefaultContextPrompt ? null : this._contextPrompt - }; + var state = new Mem0State(this._storageScope, this._searchScope); var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions; return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(Mem0State))); @@ -262,10 +260,10 @@ public sealed class Mem0Provider : AIContextProvider } await this._client.CreateMemoryAsync( - this.ApplicationId, - this.AgentId, - this.ThreadId, - this.UserId, + this._storageScope.ApplicationId, + this._storageScope.AgentId, + this._storageScope.ThreadId, + this._storageScope.UserId, message.Text, message.Role.Value, cancellationToken).ConfigureAwait(false); @@ -274,10 +272,14 @@ public sealed class Mem0Provider : AIContextProvider internal sealed class Mem0State { - public string? ApplicationId { get; set; } - public string? AgentId { get; set; } - public string? UserId { get; set; } - public string? ThreadId { get; set; } - public string? ContextPrompt { get; set; } + [JsonConstructor] + public Mem0State(Mem0ProviderScope storageScope, Mem0ProviderScope searchScope) + { + this.StorageScope = storageScope; + this.SearchScope = searchScope; + } + + public Mem0ProviderScope StorageScope { get; set; } + public Mem0ProviderScope SearchScope { get; set; } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs index f9987698a8..34b0392bec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs @@ -5,35 +5,8 @@ namespace Microsoft.Agents.AI.Mem0; /// /// Options for configuring the . /// -/// -/// Mem0 memories can be scoped by one or more of: application, agent, thread, and user. -/// At least one scope must be provided; otherwise Mem0 will reject requests. -/// public sealed class Mem0ProviderOptions { - /// - /// Gets or sets an optional ID for the application to scope memories to. - /// - /// If not set, the scope of the memories will span all applications. - public string? ApplicationId { get; set; } - - /// - /// Gets or sets an optional ID for the agent to scope memories to. - /// - /// If not set, the scope of the memories will span all agents. - public string? AgentId { get; set; } - - /// - /// Gets or sets an optional ID for the thread to scope memories to. - /// - public string? ThreadId { get; set; } - - /// - /// Gets or sets an optional ID for the user to scope memories to. - /// - /// If not set, the scope of the memories will span all users. - public string? UserId { get; set; } - /// /// When providing memories to the model, this string is prefixed to the retrieved memories to supply context. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderScope.cs new file mode 100644 index 0000000000..ff47549b39 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderScope.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Mem0; + +/// +/// Allows scoping of memories for the . +/// +/// +/// Mem0 memories can be scoped by one or more of: application, agent, thread, and user. +/// At least one scope must be provided; otherwise Mem0 will reject requests. +/// +public sealed class Mem0ProviderScope +{ + /// + /// Initializes a new instance of the class. + /// + public Mem0ProviderScope() { } + + /// + /// Initializes a new instance of the class by cloning an existing scope. + /// + /// The scope to clone. + public Mem0ProviderScope(Mem0ProviderScope sourceScope) + { + Throw.IfNull(sourceScope); + + this.ApplicationId = sourceScope.ApplicationId; + this.AgentId = sourceScope.AgentId; + this.ThreadId = sourceScope.ThreadId; + this.UserId = sourceScope.UserId; + } + + /// + /// Gets or sets an optional ID for the application to scope memories to. + /// + /// If not set, the scope of the memories will span all applications. + public string? ApplicationId { get; set; } + + /// + /// Gets or sets an optional ID for the agent to scope memories to. + /// + /// If not set, the scope of the memories will span all agents. + public string? AgentId { get; set; } + + /// + /// Gets or sets an optional ID for the thread to scope memories to. + /// + public string? ThreadId { get; set; } + + /// + /// Gets or sets an optional ID for the user to scope memories to. + /// + /// If not set, the scope of the memories will span all users. + public string? UserId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs index 8cb4582e37..f76629a577 100644 --- a/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Data/TextSearchProvider.cs @@ -44,82 +44,72 @@ public sealed class TextSearchProvider : AIContextProvider private readonly ILogger? _logger; private readonly AITool[] _tools; private readonly Queue _recentMessagesText; - private readonly TextSearchProviderOptions _options; private readonly List _recentMessageRolesIncluded; + private readonly int _recentMessageMemoryLimit; + private readonly TextSearchProviderOptions.TextSearchBehavior _searchTime; + private readonly string _contextPrompt; + private readonly string _citationsPrompt; + private readonly Func, string>? _contextFormatter; /// /// Initializes a new instance of the class. /// /// Delegate that executes the search logic. Must not be . - /// Optional configuration options. - /// Optional logger factory. - /// Thrown when is . - public TextSearchProvider(Func>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - { - this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync)); - this._options = options ?? new(); - Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0); - this._logger = loggerFactory?.CreateLogger(); - this._recentMessagesText = new(); - this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User]; - - // Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling) - this._tools = - [ - AIFunctionFactory.Create( - this.SearchAsync, - name: this._options.FunctionToolName ?? DefaultPluginSearchFunctionName, - description: this._options.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription) - ]; - } - - /// - /// Initializes a new instance of the class from previously serialized state. - /// - /// Delegate that executes the search logic. Must not be . /// A representing the serialized provider state. /// Optional serializer options (unused - source generated context is used). /// Optional configuration options. /// Optional logger factory. /// Thrown when is . - /// - /// Only overridden prompts (function name, function description, context prompt, citations prompt) are restored. - /// If a value was not persisted or matches the defaults it will fall back to the built-in defaults. - /// Custom delegates are not serialized. - /// - public TextSearchProvider(Func>> searchAsync, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + public TextSearchProvider( + Func>> searchAsync, + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + TextSearchProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) { - this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync)); - this._options = options ?? new(); - Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0); + // Validate and assign parameters + this._searchAsync = Throw.IfNull(searchAsync); this._logger = loggerFactory?.CreateLogger(); - this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User]; + this._recentMessageMemoryLimit = Throw.IfLessThan(options?.RecentMessageMemoryLimit ?? 0, 0); + this._recentMessageRolesIncluded = options?.RecentMessageRolesIncluded ?? [ChatRole.User]; + this._searchTime = options?.SearchTime ?? TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke; + this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; + this._citationsPrompt = options?.CitationsPrompt ?? DefaultCitationsPrompt; + this._contextFormatter = options?.ContextFormatter; + // Restore recent messages from serialized state if provided List? restoredMessages = null; - - var state = serializedState.Deserialize(AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(TextSearchProviderState))) as TextSearchProviderState; - if (state?.RecentMessagesText is { Count: > 0 }) + if (serializedState.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) { - restoredMessages = state.RecentMessagesText; + this._recentMessagesText = new(); } + else + { + var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(TextSearchProviderState))) as TextSearchProviderState; + if (state?.RecentMessagesText is { Count: > 0 }) + { + restoredMessages = state.RecentMessagesText; + } - // Restore recent messages respecting the limit (may truncate if limit changed afterwards). - this._recentMessagesText = restoredMessages is null ? new() : new(restoredMessages.Take(this._options.RecentMessageMemoryLimit)); + // Restore recent messages respecting the limit (may truncate if limit changed afterwards). + this._recentMessagesText = restoredMessages is null ? new() : new(restoredMessages.Take(this._recentMessageMemoryLimit)); + } // Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling) this._tools = [ AIFunctionFactory.Create( this.SearchAsync, - name: this._options.FunctionToolName ?? DefaultPluginSearchFunctionName, - description: this._options.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription) + name: options?.FunctionToolName ?? DefaultPluginSearchFunctionName, + description: options?.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription) ]; } /// public override async ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) { - if (this._options.SearchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) + if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) { // Expose the search tool for on-demand invocation. return new AIContext { Tools = this._tools }; // No automatic message injection. @@ -171,7 +161,7 @@ public sealed class TextSearchProvider : AIContextProvider /// public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) { - int limit = this._options.RecentMessageMemoryLimit; + int limit = this._recentMessageMemoryLimit; if (limit <= 0) { return default; // Memory disabled. @@ -220,9 +210,9 @@ public sealed class TextSearchProvider : AIContextProvider { // Only persist values that differ from defaults plus recent memory configuration & messages. TextSearchProviderState state = new(); - if (this._options.RecentMessageMemoryLimit > 0 && this._recentMessagesText.Count > 0) + if (this._recentMessageMemoryLimit > 0 && this._recentMessagesText.Count > 0) { - state.RecentMessagesText = this._recentMessagesText.Take(this._options.RecentMessageMemoryLimit).ToList(); + state.RecentMessagesText = this._recentMessagesText.Take(this._recentMessageMemoryLimit).ToList(); } return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(TextSearchProviderState))); @@ -253,9 +243,9 @@ public sealed class TextSearchProvider : AIContextProvider /// Formatted string (may be empty). private string FormatResults(IList results) { - if (this._options.ContextFormatter is not null) + if (this._contextFormatter is not null) { - return this._options.ContextFormatter(results) ?? string.Empty; + return this._contextFormatter(results) ?? string.Empty; } if (results.Count == 0) @@ -264,7 +254,7 @@ public sealed class TextSearchProvider : AIContextProvider } var sb = new StringBuilder(); - sb.AppendLine(this._options.ContextPrompt ?? DefaultContextPrompt); + sb.AppendLine(this._contextPrompt); for (int i = 0; i < results.Count; i++) { var result = results[i]; @@ -279,7 +269,7 @@ public sealed class TextSearchProvider : AIContextProvider sb.AppendLine($"Contents: {result.Text}"); sb.AppendLine("----"); } - sb.AppendLine(this._options.CitationsPrompt ?? DefaultCitationsPrompt); + sb.AppendLine(this._citationsPrompt); sb.AppendLine(); return sb.ToString(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs index 0b9594845a..5240eccd05 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Mem0ProviderTests.cs @@ -45,8 +45,8 @@ public sealed class Mem0ProviderTests : IDisposable // Arrange var question = new ChatMessage(ChatRole.User, "What is my name?"); var input = new ChatMessage(ChatRole.User, "Hello, my name is Caoimhe."); - var options = new Mem0ProviderOptions { ThreadId = "it-thread-1", UserId = "it-user-1" }; - var sut = new Mem0Provider(this._httpClient, options); + var storageScope = new Mem0ProviderScope { ThreadId = "it-thread-1", UserId = "it-user-1" }; + var sut = new Mem0Provider(this._httpClient, storageScope); await sut.ClearStoredMemoriesAsync(); var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); @@ -69,8 +69,8 @@ public sealed class Mem0ProviderTests : IDisposable // Arrange var question = new ChatMessage(ChatRole.User, "What is your name?"); var assistantIntro = new ChatMessage(ChatRole.Assistant, "Hello, I'm a friendly assistant and my name is Caoimhe."); - var options = new Mem0ProviderOptions { AgentId = "it-agent-1" }; - var sut = new Mem0Provider(this._httpClient, options); + var storageScope = new Mem0ProviderScope { AgentId = "it-agent-1" }; + var sut = new Mem0Provider(this._httpClient, storageScope); await sut.ClearStoredMemoriesAsync(); var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question })); @@ -93,8 +93,8 @@ public sealed class Mem0ProviderTests : IDisposable // Arrange var question = new ChatMessage(ChatRole.User, "What is your name?"); var assistantIntro = new ChatMessage(ChatRole.Assistant, "I'm an AI tutor and my name is Caoimhe."); - var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-a" }); - var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-b" }); + var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-a" }); + var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-b" }); await sut1.ClearStoredMemoriesAsync(); await sut2.ClearStoredMemoriesAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index ae97aed78c..46a5482f15 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -48,35 +48,35 @@ public sealed class Mem0ProviderTests : IDisposable using HttpClient client = new(); // Act & Assert - var ex = Assert.Throws(() => new Mem0Provider(client)); + var ex = Assert.Throws(() => new Mem0Provider(client, new Mem0ProviderScope() { ThreadId = "tid" })); Assert.StartsWith("The HttpClient BaseAddress must be set for Mem0 operations.", ex.Message); } [Fact] - public void Constructor_Defaults_Scopes() + public void Constructor_Throws_WhenNoStorageScopeValueIsSet() { - // Arrange & Act - var sut = new Mem0Provider(this._httpClient); - - // Assert - Assert.Null(sut.ApplicationId); - Assert.Null(sut.AgentId); - Assert.Null(sut.ThreadId); - Assert.Null(sut.UserId); + // Act & Assert + var ex = Assert.Throws(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope())); + Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope.", ex.Message); } [Fact] - public void DeserializingConstructor_Defaults_Scopes() + public void Constructor_Throws_WhenNoSearchScopeValueIsSet() { - // Arrange & Act - var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions); - var sut = new Mem0Provider(this._httpClient, jsonElement); + // Act & Assert + var ex = Assert.Throws(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope() { ThreadId = "tid" }, new Mem0ProviderScope())); + Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope.", ex.Message); + } - // Assert - Assert.Null(sut.ApplicationId); - Assert.Null(sut.AgentId); - Assert.Null(sut.ThreadId); - Assert.Null(sut.UserId); + [Fact] + public void DeserializingConstructor_Throws_WithEmptyJsonElement() + { + // Arrange + var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions); + + // Act & Assert + var ex = Assert.Throws(() => new Mem0Provider(this._httpClient, jsonElement)); + Assert.StartsWith("The Mem0Provider state did not contain the required scope properties.", ex.Message); } [Fact] @@ -84,14 +84,14 @@ public sealed class Mem0ProviderTests : IDisposable { // Arrange this._handler.EnqueueJsonResponse("[ { \"id\": \"1\", \"memory\": \"Name is Caoimhe\", \"hash\": \"h\", \"metadata\": null, \"score\": 0.9, \"created_at\": \"2023-01-01T00:00:00Z\", \"updated_at\": null, \"user_id\": \"u\", \"app_id\": null, \"agent_id\": \"agent\", \"session_id\": \"thread\" } ]"); - var options = new Mem0ProviderOptions + var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" }; - var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object); + var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") }); // Act @@ -137,8 +137,8 @@ public sealed class Mem0ProviderTests : IDisposable this._handler.EnqueueEmptyOk(); // For first CreateMemory this._handler.EnqueueEmptyOk(); // For second CreateMemory this._handler.EnqueueEmptyOk(); // For third CreateMemory - var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; - var sut = new Mem0Provider(this._httpClient, options); + var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; + var sut = new Mem0Provider(this._httpClient, storageScope); var requestMessages = new List { @@ -168,8 +168,8 @@ public sealed class Mem0ProviderTests : IDisposable public async Task InvokedAsync_PersistsNothingForFailedRequestAsync() { // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; - var sut = new Mem0Provider(this._httpClient, options); + var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; + var sut = new Mem0Provider(this._httpClient, storageScope); var requestMessages = new List { @@ -189,8 +189,8 @@ public sealed class Mem0ProviderTests : IDisposable public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync() { // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; - var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object); + var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" }; + var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object); this._handler.EnqueueEmptyInternalServerError(); var requestMessages = new List @@ -222,8 +222,8 @@ public sealed class Mem0ProviderTests : IDisposable public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync() { // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" }; - var sut = new Mem0Provider(this._httpClient, options); + var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" }; + var sut = new Mem0Provider(this._httpClient, storageScope); this._handler.EnqueueEmptyOk(); // for DELETE // Act @@ -235,80 +235,39 @@ public sealed class Mem0ProviderTests : IDisposable } [Fact] - public void Properties_Roundtrip() + public void Serialize_RoundTripsScopes() { // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" }; - var sut = new Mem0Provider(this._httpClient, options); - - // Assert - Assert.Equal("app", sut.ApplicationId); - Assert.Equal("agent", sut.AgentId); - Assert.Equal("thread", sut.ThreadId); - Assert.Equal("user", sut.UserId); - - // Act - sut.ApplicationId = "app2"; - sut.AgentId = "agent2"; - sut.ThreadId = "thread2"; - sut.UserId = "user2"; - - // Assert - Assert.Equal("app2", sut.ApplicationId); - Assert.Equal("agent2", sut.AgentId); - Assert.Equal("thread2", sut.ThreadId); - Assert.Equal("user2", sut.UserId); - } - - [Fact] - public void Serialize_Deserialize_Roundtrips() - { - // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" }; - var sut = new Mem0Provider(this._httpClient, options); - - // Act - var stateElement = sut.Serialize(); - var sut2 = new Mem0Provider(this._httpClient, stateElement); - - // Assert - Assert.Equal("app", sut.ApplicationId); - Assert.Equal("agent", sut.AgentId); - Assert.Equal("thread", sut.ThreadId); - Assert.Equal("user", sut.UserId); - - Assert.Equal("app", sut2.ApplicationId); - Assert.Equal("agent", sut2.AgentId); - Assert.Equal("thread", sut2.ThreadId); - Assert.Equal("user", sut2.UserId); - } - - [Fact] - public void Serialize_RoundTripsCustomContextPrompt() - { - // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user", ContextPrompt = "Custom:" }; - var sut = new Mem0Provider(this._httpClient, options); + var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" }; + var sut = new Mem0Provider(this._httpClient, storageScope, options: new() { ContextPrompt = "Custom:" }, loggerFactory: this._loggerFactoryMock.Object); // Act var stateElement = sut.Serialize(); using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText()); - Assert.Equal("Custom:", doc.RootElement.GetProperty("contextPrompt").GetString()); + var storageScopeElement = doc.RootElement.GetProperty("storageScope"); + Assert.Equal("app", storageScopeElement.GetProperty("applicationId").GetString()); + Assert.Equal("agent", storageScopeElement.GetProperty("agentId").GetString()); + Assert.Equal("thread", storageScopeElement.GetProperty("threadId").GetString()); + Assert.Equal("user", storageScopeElement.GetProperty("userId").GetString()); var sut2 = new Mem0Provider(this._httpClient, stateElement); var stateElement2 = sut2.Serialize(); // Assert using JsonDocument doc2 = JsonDocument.Parse(stateElement2.GetRawText()); - Assert.Equal("Custom:", doc2.RootElement.GetProperty("contextPrompt").GetString()); + var storageScopeElement2 = doc2.RootElement.GetProperty("storageScope"); + Assert.Equal("app", storageScopeElement2.GetProperty("applicationId").GetString()); + Assert.Equal("agent", storageScopeElement2.GetProperty("agentId").GetString()); + Assert.Equal("thread", storageScopeElement2.GetProperty("threadId").GetString()); + Assert.Equal("user", storageScopeElement2.GetProperty("userId").GetString()); } [Fact] public void Serialize_DoesNotIncludeDefaultContextPrompt() { // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "app" }; - var sut = new Mem0Provider(this._httpClient, options); + var storageScope = new Mem0ProviderScope { ApplicationId = "app" }; + var sut = new Mem0Provider(this._httpClient, storageScope); // Act var stateElement = sut.Serialize(); @@ -318,23 +277,12 @@ public sealed class Mem0ProviderTests : IDisposable Assert.False(doc.RootElement.TryGetProperty("contextPrompt", out _)); } - [Fact] - public async Task InvokingAsync_Throws_WhenNoScopesAsync() - { - // Arrange - var sut = new Mem0Provider(this._httpClient, new Mem0ProviderOptions()); - var ctx = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Test") }); - - // Act & Assert - await Assert.ThrowsAsync(() => sut.InvokingAsync(ctx).AsTask()); - } - [Fact] public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync() { // Arrange - var options = new Mem0ProviderOptions { ApplicationId = "app" }; - var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object); + var storageScope = new Mem0ProviderScope { ApplicationId = "app" }; + var provider = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index 831c843337..be1e901499 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -58,7 +58,7 @@ public sealed class TextSearchProviderTests ContextPrompt = overrideContextPrompt, CitationsPrompt = overrideCitationsPrompt }; - var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options, withLogging ? this._loggerFactoryMock.Object : null); var invokingContext = new AIContextProvider.InvokingContext(new[] { @@ -135,7 +135,7 @@ public sealed class TextSearchProviderTests FunctionToolName = overrideName, FunctionToolDescription = overrideDescription }; - var provider = new TextSearchProvider(this.NoResultSearchAsync, options); + var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); // Act @@ -154,7 +154,7 @@ public sealed class TextSearchProviderTests public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync() { // Arrange - var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object); + var provider = new TextSearchProvider(this.FailingSearchAsync, default, null, loggerFactory: this._loggerFactoryMock.Object); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); // Act @@ -195,7 +195,7 @@ public sealed class TextSearchProviderTests ContextPrompt = overrideContextPrompt, CitationsPrompt = overrideCitationsPrompt }; - var provider = new TextSearchProvider(SearchDelegateAsync, options); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options); // Act var formatted = await provider.SearchAsync("Sample user question?", CancellationToken.None); @@ -247,7 +247,7 @@ public sealed class TextSearchProviderTests SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, ContextFormatter = r => $"Custom formatted context with {r.Count} results." }; - var provider = new TextSearchProvider(SearchDelegateAsync, options); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); // Act @@ -281,7 +281,7 @@ public sealed class TextSearchProviderTests SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, ContextFormatter = r => string.Join(",", r.Select(x => ((RawPayload)x.RawRepresentation!).Id)) }; - var provider = new TextSearchProvider(SearchDelegateAsync, options); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); // Act @@ -298,7 +298,7 @@ public sealed class TextSearchProviderTests { // Arrange var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke }; - var provider = new TextSearchProvider(this.NoResultSearchAsync, options); + var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options); var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") }); // Act @@ -327,7 +327,7 @@ public sealed class TextSearchProviderTests capturedInput = input; return Task.FromResult>([]); // No results needed. } - var provider = new TextSearchProvider(SearchDelegateAsync, options); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options); // Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D var initialMessages = new[] @@ -367,7 +367,7 @@ public sealed class TextSearchProviderTests capturedInput = input; return Task.FromResult>([]); // No results needed. } - var provider = new TextSearchProvider(SearchDelegateAsync, options); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options); // Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D var initialMessages = new[] @@ -407,7 +407,7 @@ public sealed class TextSearchProviderTests capturedInput = input; return Task.FromResult>([]); } - var provider = new TextSearchProvider(SearchDelegateAsync, options); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options); // First memory update (A,B) await provider.InvokedAsync(new(new[] @@ -449,7 +449,7 @@ public sealed class TextSearchProviderTests capturedInput = input; return Task.FromResult>([]); // No results needed for this test. } - var provider = new TextSearchProvider(SearchDelegateAsync, options); + var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options); // Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained. var initialMessages = new[] @@ -486,7 +486,7 @@ public sealed class TextSearchProviderTests SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 3 }; - var provider = new TextSearchProvider(this.NoResultSearchAsync, options); + var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options); // Act var state = provider.Serialize(); @@ -506,7 +506,7 @@ public sealed class TextSearchProviderTests RecentMessageMemoryLimit = 3, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; - var provider = new TextSearchProvider(this.NoResultSearchAsync, options); + var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options); var messages = new[] { new ChatMessage(ChatRole.User, "M1"), @@ -536,7 +536,7 @@ public sealed class TextSearchProviderTests RecentMessageMemoryLimit = 4, RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant] }; - var provider = new TextSearchProvider(this.NoResultSearchAsync, options); + var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options); var messages = new[] { new ChatMessage(ChatRole.User, "A"), @@ -571,7 +571,7 @@ public sealed class TextSearchProviderTests public async Task Deserialize_WithChangedLowerLimit_ShouldTruncateToNewLimitAsync() { // Arrange - var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions + var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, default, null, new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 5, From 6ca907f23fd5a5f143c6fde2cab81e3f1f6aa08c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:28:00 +0000 Subject: [PATCH 15/16] .NET: [Breaking Change] Moving MAAI.AzureAI V1 Package -> MAAI.AzureAI.Persistent (V1) (#1902) * Update AzureAI -> AzureAI.Persistent * Fix sample reference --- .github/upgrades/prompts/SemanticKernelToAgentFramework.md | 2 +- dotnet/agent-framework-dotnet.slnx | 4 ++-- dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj | 2 +- .../Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj | 2 +- .../Catalog/DeepResearchAgent/DeepResearchAgent.csproj | 2 +- .../Agent_With_AzureFoundryAgent.csproj | 2 +- .../Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj | 2 +- .../FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj | 2 +- .../Agents/CustomAgentExecutors/CustomAgentExecutors.csproj | 2 +- .../Workflows/Agents/FoundryAgent/FoundryAgent.csproj | 2 +- .../Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj | 2 +- .../Workflows/Concurrent/Concurrent/Concurrent.csproj | 2 +- .../ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj | 2 +- .../ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj | 2 +- .../03_MultiSelection/03_MultiSelection.csproj | 2 +- .../WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj | 2 +- .../03_AgentsInWorkflows/03_AgentsInWorkflows.csproj | 2 +- .../04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj | 2 +- .../05_MultiModelService/05_MultiModelService.csproj | 2 +- .../_Foundational/06_SubWorkflows/06_SubWorkflows.csproj | 2 +- .../07_MixedWorkflowAgentsAndExecutors.csproj | 2 +- .../08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj | 2 +- .../Microsoft.Agents.AI.AzureAI.Persistent.csproj} | 0 .../PersistentAgentsClientExtensions.cs | 0 .../Microsoft.Agents.AI.Workflows.Declarative.csproj | 2 +- .../AzureAIAgentsPersistent.IntegrationTests.csproj | 2 +- .../Extensions/PersistentAgentsClientExtensionsTests.cs | 2 +- .../Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj} | 2 +- 28 files changed, 27 insertions(+), 27 deletions(-) rename dotnet/src/{Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj => Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj} (100%) rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.AzureAI.Persistent}/PersistentAgentsClientExtensions.cs (100%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.AzureAI.Persistent.UnitTests}/Extensions/PersistentAgentsClientExtensionsTests.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj => Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj} (78%) diff --git a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md index 2f2a6886c7..a121a5f446 100644 --- a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md +++ b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md @@ -1052,7 +1052,7 @@ AgentThread thread = agent.GetNewThread(); **Add Agent Framework Packages:** ```xml - + ``` diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 86eee2fbbe..42d89199f4 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -272,7 +272,7 @@ - + @@ -298,7 +298,7 @@ - + diff --git a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj index a053b9e33b..8d67180f64 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj +++ b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj @@ -20,7 +20,7 @@ - + diff --git a/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj index 284ea6ceec..f192c19901 100644 --- a/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj +++ b/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj b/dotnet/samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj index d006616a15..7ae71d83de 100644 --- a/dotnet/samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj +++ b/dotnet/samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Agent_With_AzureFoundryAgent.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Agent_With_AzureFoundryAgent.csproj index b8d701fbcd..11c7beb3bf 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Agent_With_AzureFoundryAgent.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryAgent/Agent_With_AzureFoundryAgent.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj index f0cdbfccc7..1fb367c044 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj @@ -18,7 +18,7 @@ - + diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj index b8d701fbcd..11c7beb3bf 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj index 354163794e..51b18bdeb2 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj index 1d24b7253b..888274205a 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj index 354163794e..51b18bdeb2 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj index e11fd9fa9a..3f3fe6d56c 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj index 76f9509ee1..17b1cb882a 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj index 76f9509ee1..17b1cb882a 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj index 76f9509ee1..17b1cb882a 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj index 17c44eeb75..2193722d26 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj @@ -20,7 +20,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj index 354163794e..51b18bdeb2 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj index 354163794e..51b18bdeb2 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj index 1af928d9b8..ea370c4eaa 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj index 1ef94de3da..89b1e4bbe0 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj index 354163794e..51b18bdeb2 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj @@ -16,7 +16,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj index 3e8f2547d1..24901257c8 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj rename to dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index b3b2a86ab4..7db0b0d941 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -35,7 +35,7 @@ - + diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj index f5cafd2975..966ea64020 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj @@ -7,7 +7,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs index 2405cd3347..56b89d2df8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs @@ -13,7 +13,7 @@ using Azure.Core; using Microsoft.Extensions.AI; using Moq; -namespace Microsoft.Agents.AI.AzureAI.UnitTests.Extensions; +namespace Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.Extensions; public sealed class PersistentAgentsClientExtensionsTests { diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj similarity index 78% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj rename to dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj index a96251098b..80c0086675 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj @@ -5,7 +5,7 @@ - + From 77d882e2b44d56029a33d0c7d9af5b5f65acbff1 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 5 Nov 2025 07:31:36 -0800 Subject: [PATCH 16/16] Fix workflow lookup with AddAsAIAgent(name) when name differs from workflow name (#1925) --- .../HostedWorkflowBuilderExtensions.cs | 8 +- ...plicationBuilderWorkflowExtensionsTests.cs | 191 ++++++++++++++++++ 2 files changed, 196 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs index f303415255..ca3d84fa86 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs @@ -26,8 +26,10 @@ public static class HostedWorkflowBuilderExtensions /// An that can be used to further configure the agent. public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name) { - var agentName = name ?? builder.Name; - return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) => sp.GetRequiredKeyedService(key) - .AsAgent(name: key)); + var workflowName = builder.Name; + var agentName = name ?? workflowName; + + return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) => + sp.GetRequiredKeyedService(workflowName).AsAgent(name: key)); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs index 6c4250943e..fc96af2637 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -233,6 +233,197 @@ public class HostApplicationBuilderWorkflowExtensionsTests Assert.Equal("agentBuilders", exception.ParamName); } + /// + /// Verifies that AddAsAIAgent without a name parameter uses the workflow name as the agent name. + /// + [Fact] + public void AddAsAIAgent_WithoutName_UsesWorkflowName() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var agentBuilder = workflowBuilder.AddAsAIAgent(); + + Assert.NotNull(agentBuilder); + + // Verify workflow is registered with workflow name + var workflowDescriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow)); + Assert.NotNull(workflowDescriptor); + + // Verify agent is registered with workflow name + var agentDescriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent)); + Assert.NotNull(agentDescriptor); + } + + /// + /// Verifies that AddAsAIAgent with a name parameter uses that name instead of the workflow name. + /// + [Fact] + public void AddAsAIAgent_WithName_UsesProvidedName() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + const string AgentName = "testAgent"; + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var agentBuilder = workflowBuilder.AddAsAIAgent(AgentName); + + Assert.NotNull(agentBuilder); + + // Verify workflow is registered with workflow name + var workflowDescriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow)); + Assert.NotNull(workflowDescriptor); + + // Verify agent is registered with agent name (not workflow name) + var agentDescriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == AgentName && d.ServiceType == typeof(AIAgent)); + Assert.NotNull(agentDescriptor); + + // Verify no agent registered with workflow name + var wrongAgentDescriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent)); + Assert.NotSame(workflowDescriptor, wrongAgentDescriptor); + } + + /// + /// Verifies that AddAsAIAgent correctly retrieves the workflow using the workflow name, not the agent name. + /// + [Fact] + public void AddAsAIAgent_WithDifferentName_RetrievesWorkflowCorrectly() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "myWorkflow"; + const string AgentName = "myAgent"; + + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + workflowBuilder.AddAsAIAgent(AgentName); + + var serviceProvider = builder.Build().Services; + + // Act - Get the agent using the agent name + var agent = serviceProvider.GetRequiredKeyedService(AgentName); + + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + + // Verify that we can still get the workflow using the workflow name + var workflow = serviceProvider.GetRequiredKeyedService(WorkflowName); + Assert.NotNull(workflow); + Assert.Equal(WorkflowName, workflow.Name); + } + + /// + /// Verifies that AddAsAIAgent returns IHostedAgentBuilder with correct name. + /// + [Fact] + public void AddAsAIAgent_ReturnsHostedAgentBuilder() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + const string AgentName = "testAgent"; + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var agentBuilder = workflowBuilder.AddAsAIAgent(AgentName); + + Assert.NotNull(agentBuilder); + Assert.IsAssignableFrom(agentBuilder); + Assert.Equal(AgentName, agentBuilder.Name); + } + + /// + /// Verifies that AddAsAIAgent without name returns IHostedAgentBuilder with workflow name. + /// + [Fact] + public void AddAsAIAgent_WithoutName_ReturnsHostedAgentBuilderWithWorkflowName() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var agentBuilder = workflowBuilder.AddAsAIAgent(); + + Assert.NotNull(agentBuilder); + Assert.IsAssignableFrom(agentBuilder); + Assert.Equal(WorkflowName, agentBuilder.Name); + } + + /// + /// Verifies that AddAsAIAgent can chain multiple agents from the same workflow. + /// + [Fact] + public void AddAsAIAgent_MultipleAgents_FromSameWorkflow() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var agentBuilder1 = workflowBuilder.AddAsAIAgent("agent1"); + var agentBuilder2 = workflowBuilder.AddAsAIAgent("agent2"); + + Assert.NotNull(agentBuilder1); + Assert.NotNull(agentBuilder2); + + // Verify both agents are registered + var agentDescriptor1 = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == "agent1" && d.ServiceType == typeof(AIAgent)); + var agentDescriptor2 = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == "agent2" && d.ServiceType == typeof(AIAgent)); + + Assert.NotNull(agentDescriptor1); + Assert.NotNull(agentDescriptor2); + + // Verify workflow is registered only once + var workflowDescriptors = builder.Services.Where( + d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow)).ToList(); + Assert.Single(workflowDescriptors); + } + + /// + /// Verifies that AddAsAIAgent with null name behaves the same as the parameterless overload. + /// + [Fact] + public void AddAsAIAgent_WithNullName_UsesWorkflowName() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var agentBuilder = workflowBuilder.AddAsAIAgent(name: null); + + Assert.NotNull(agentBuilder); + Assert.Equal(WorkflowName, agentBuilder.Name); + + // Verify agent is registered with workflow name + var agentDescriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent)); + Assert.NotNull(agentDescriptor); + } + + /// + /// Verifies that AddAsAIAgent with empty string name uses empty string as agent name. + /// + [Fact] + public void AddAsAIAgent_WithEmptyName_UsesEmptyStringAsAgentName() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var agentBuilder = workflowBuilder.AddAsAIAgent(name: ""); + + Assert.NotNull(agentBuilder); + Assert.Equal("", agentBuilder.Name); + + // Verify agent is registered with empty string name + var agentDescriptor = builder.Services.FirstOrDefault( + d => d.ServiceKey is string s && s.Length == 0 && d.ServiceType == typeof(AIAgent)); + Assert.NotNull(agentDescriptor); + } + /// /// Helper method to create a simple test workflow with a given name. ///