From c8e3cd0d74924d27f4bb2d347bbc1212e2714358 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:38:22 +0100 Subject: [PATCH 01/32] .Net: Upgrade dependencies (#13927) ### Motivation and Context ### Description ### Contribution Checklist - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: --- dotnet/Directory.Packages.props | 41 ++++++++++--------- .../TelemetryWithAppInsights.csproj | 4 ++ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 95db60cb6f98..9cae6f0a8648 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -24,8 +24,8 @@ - - + + @@ -65,7 +65,7 @@ - + @@ -92,17 +92,20 @@ - - - - - - - + + + + + + + + + + - + @@ -110,9 +113,9 @@ - - - + + + @@ -120,10 +123,10 @@ - - + + - + @@ -139,7 +142,7 @@ - + @@ -152,7 +155,7 @@ - + diff --git a/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj b/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj index 2b245bf45b41..e75746d4b69e 100644 --- a/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj +++ b/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj @@ -13,6 +13,10 @@ + + + + From 27971e118b344a5bedea6bbd5c514d3409890b54 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:14:15 +0100 Subject: [PATCH 02/32] .Net: Sk release version 2026 04 27 (#13929) ### Motivation and Context ### Description ### Contribution Checklist - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: --- dotnet/nuget/nuget-package.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 016deb5eaf36..8dc75729a794 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,7 +1,7 @@ - 1.74.0 + 1.75.0 $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix) @@ -9,7 +9,7 @@ true - 1.73.0 + 1.74.0 $(NoWarn);CP0003 From ea9981ffaf4df6547352f03df4cb7e3a71172869 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:18:01 +0900 Subject: [PATCH 03/32] Python: Docs: Add Microsoft Agent Framework successor callout to READMEs (#13932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation and Context Now that **Microsoft Agent Framework (MAF)** is the successor SDK to Semantic Kernel, the top-level and per-language READMEs should direct community members — especially newcomers landing here from search, GitHub, PyPI, or NuGet — to MAF for new work, and clearly state SK's ongoing maintenance posture. ## Description Adds a prominent GitHub `> [!IMPORTANT]` callout, placed directly under the existing title/badges block, to: - `README.md` (top-level) - `python/README.md` - `dotnet/README.md` Each callout: - Names MAF as Semantic Kernel's successor SDK and links to https://github.com/microsoft/agent-framework. - Describes MAF as "an open, multi-language framework for building production-grade AI agents and multi-agent workflows in .NET and Python". - Asks readers to use MAF for the latest abstractions to build agents, workflows, and multi-agent orchestrations. - States that Semantic Kernel will continue to receive minimal security and bug fixes only. No other README content is modified; SK identity, badges, install steps, and quickstart samples are preserved. ## Contribution Checklist - [x] The code builds clean without any errors or warnings — docs-only change. - [x] I didn't break anyone — additive callout only. - [x] All relevant CONTRIBUTING.md sections were followed. --- README.md | 6 +++++- dotnet/README.md | 5 +++++ python/README.md | 16 ++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee5a6ebd6571..02cab29b8f98 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Semantic Kernel +> [!IMPORTANT] +> Semantic Kernel is now [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)! Microsoft Agent Framework (MAF) is the enterprise‑ready successor to Semantic Kernel. Microsoft Agent Framework is now available at version 1.0 as a production-ready release: stable APIs, and a commitment to long-term support. Whether you're building a single assistant or orchestrating a fleet of specialized agents, Microsoft Agent Framework 1.0 gives you enterprise-grade multi-agent orchestration, multi-provider model support, and cross-runtime interoperability via A2A and MCP. +> +> Learn more about Semantic Kernel and Agent Framework here: [Semantic Kernel and Microsoft Agent Framework on the Agent Framework blog](https://devblogs.microsoft.com/agent-framework/semantic-kernel-and-microsoft-agent-framework/), and try out the [Semantic Kernel migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel). + **Build intelligent AI agents and multi-agent systems with this enterprise-ready orchestration framework** [![License: MIT](https://img.shields.io/github/license/microsoft/semantic-kernel)](https://github.com/microsoft/semantic-kernel/blob/main/LICENSE) @@ -7,7 +12,6 @@ [![Nuget package](https://img.shields.io/nuget/vpre/Microsoft.SemanticKernel)](https://www.nuget.org/packages/Microsoft.SemanticKernel/) [![Discord](https://img.shields.io/discord/1063152441819942922?label=Discord&logo=discord&logoColor=white&color=d82679)](https://aka.ms/SKDiscord) - ## What is Semantic Kernel? Semantic Kernel is a model-agnostic SDK that empowers developers to build, orchestrate, and deploy AI agents and multi-agent systems. Whether you're building a simple chatbot or a complex multi-agent workflow, Semantic Kernel provides the tools you need with enterprise-grade reliability and flexibility. diff --git a/dotnet/README.md b/dotnet/README.md index b208a8f65335..f65bc4d5f49a 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,5 +1,10 @@ # Get Started with Semantic Kernel ⚡ +> [!IMPORTANT] +> Semantic Kernel is now [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)! Microsoft Agent Framework (MAF) is the enterprise‑ready successor to Semantic Kernel. Microsoft Agent Framework is now available at version 1.0 as a production-ready release: stable APIs, and a commitment to long-term support. Whether you're building a single assistant or orchestrating a fleet of specialized agents, Microsoft Agent Framework 1.0 gives you enterprise-grade multi-agent orchestration, multi-provider model support, and cross-runtime interoperability via A2A and MCP. +> +> Learn more about Semantic Kernel and Agent Framework here: [Semantic Kernel and Microsoft Agent Framework on the Agent Framework blog](https://devblogs.microsoft.com/agent-framework/semantic-kernel-and-microsoft-agent-framework/), and try out the [Semantic Kernel migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel). + ## OpenAI / Azure OpenAI API keys To run the LLM prompts and semantic functions in the examples below, make sure diff --git a/python/README.md b/python/README.md index 128982456b6e..6dd080b09e7f 100644 --- a/python/README.md +++ b/python/README.md @@ -1,5 +1,10 @@ # Get Started with Semantic Kernel Python +> [!IMPORTANT] +> Semantic Kernel is now [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)! Microsoft Agent Framework (MAF) is the enterprise‑ready successor to Semantic Kernel. Microsoft Agent Framework is now available at version 1.0 as a production-ready release: stable APIs, and a commitment to long-term support. Whether you're building a single assistant or orchestrating a fleet of specialized agents, Microsoft Agent Framework 1.0 gives you enterprise-grade multi-agent orchestration, multi-provider model support, and cross-runtime interoperability via A2A and MCP. +> +> Learn more about Semantic Kernel and Agent Framework here: [Semantic Kernel and Microsoft Agent Framework on the Agent Framework blog](https://devblogs.microsoft.com/agent-framework/semantic-kernel-and-microsoft-agent-framework/), and try out the [Semantic Kernel migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel). + Highlights - Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems - Multi-Agent Systems: Model workflows and collaboration between AI specialists @@ -126,6 +131,7 @@ from semantic_kernel.agents import ChatCompletionAgent from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion, OpenAIChatPromptExecutionSettings from semantic_kernel.functions import kernel_function, KernelArguments + class MenuPlugin: @kernel_function(description="Provides a list of specials from the menu.") def get_specials(self) -> Annotated[str, "Returns the specials from the menu."]: @@ -141,11 +147,13 @@ class MenuPlugin: ) -> Annotated[str, "Returns the price of the menu item."]: return "$9.99" + class MenuItem(BaseModel): # Used for structured outputs price: float name: str + async def main(): # Configure structured outputs format settings = OpenAIChatPromptExecutionSettings() @@ -157,7 +165,7 @@ async def main(): name="SK-Assistant", instructions="You are a helpful assistant.", plugins=[MenuPlugin()], - arguments=KernelArguments(settings) + arguments=KernelArguments(settings), ) response = await agent.get_response("What is the price of the soup special?") @@ -166,7 +174,8 @@ async def main(): # Output: # The price of the Clam Chowder, which is the soup special, is $9.99. -asyncio.run(main()) + +asyncio.run(main()) ``` You can explore additional getting started agent samples [here](https://github.com/microsoft/semantic-kernel/tree/main/python/samples/getting_started_with_agents). @@ -181,6 +190,7 @@ from semantic_kernel.agents import ChatCompletionAgent, GroupChatOrchestration, from semantic_kernel.agents.runtime import InProcessRuntime from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion + def get_agents(): return [ ChatCompletionAgent( @@ -195,6 +205,7 @@ def get_agents(): ), ] + async def main(): agents = get_agents() group_chat = GroupChatOrchestration( @@ -215,6 +226,7 @@ async def main(): await runtime.stop_when_idle() + if __name__ == "__main__": asyncio.run(main()) ``` From 790add7a33b88271a50cef975a356d55c150ab93 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:16:18 +0200 Subject: [PATCH 04/32] Python: Bump authlib from 1.6.9 to 1.6.11 in /python (#13880) Bumps [authlib](https://github.com/authlib/authlib) from 1.6.9 to 1.6.11.
Release notes

Sourced from authlib's releases.

v1.6.11

Full Changelog: https://github.com/authlib/authlib/compare/v1.6.10...v1.6.11

  • Fix CSRF issue with starlette client

v1.6.10

Full Changelog: https://github.com/authlib/authlib/compare/v1.6.9...v1.6.10

  • Fix redirecting to unvalidated redirect_uri on UnsupportedResponseTypeError.
Changelog

Sourced from authlib's changelog.

Version 1.6.11

Released on Apr 16, 2026

  • Fix CSRF vulnerability in the Starlette OAuth client when a cache is configured.

Version 1.6.10

Released on Apr 13, 2026

  • Fix redirecting to unvalidated redirect_uri on UnsupportedResponseTypeError.
Commits
  • 0dc0e5b chore: bump to 1.6.11
  • aa7b8e4 Merge commit from fork
  • 401a770 fix: CSRF issue with starlette client
  • ef09aeb chore: release 1.6.10
  • 3be0846 fix: redirecting to unvalidated redirect_uri on UnsupportedResponseTypeError
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=authlib&package-manager=uv&previous-version=1.6.9&new-version=1.6.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/semantic-kernel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 0b65fbe34c83..9960d20fff57 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -319,14 +319,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.9" +version = "1.6.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" }, ] [[package]] @@ -6420,13 +6420,13 @@ requires-dist = [ { name = "azure-identity", specifier = ">=1.13" }, { name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.36.4,<1.41.0" }, - { name = "chromadb", marker = "extra == 'chroma'", specifier = ">=0.5,<1.1" }, + { name = "chromadb", marker = "extra == 'chroma'", specifier = ">=0.5,<1.4" }, { name = "cloudevents", specifier = "~=1.0" }, { name = "defusedxml", specifier = "~=0.7" }, { name = "faiss-cpu", marker = "extra == 'faiss'", specifier = ">=1.10.0" }, { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = "~=1.114.0" }, { name = "google-genai", marker = "extra == 'google'", specifier = "~=1.51.0" }, - { name = "ipykernel", marker = "extra == 'notebooks'", specifier = "~=6.29" }, + { name = "ipykernel", marker = "extra == 'notebooks'", specifier = ">=6.29,<8.0" }, { name = "jinja2", specifier = "~=3.1" }, { name = "mcp", specifier = ">=1.26.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8" }, @@ -6452,13 +6452,13 @@ requires-dist = [ { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'postgres'", specifier = "~=3.2" }, { name = "pyarrow", marker = "extra == 'usearch'", specifier = ">=12.0,<22.0" }, { name = "pybars4", specifier = "~=0.9" }, - { name = "pydantic", specifier = ">=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.12" }, + { name = "pydantic", specifier = ">=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.13" }, { name = "pydantic-settings", specifier = "~=2.0" }, { name = "pymilvus", marker = "extra == 'milvus'", specifier = ">=2.3,<2.7" }, - { name = "pymongo", marker = "extra == 'mongo'", specifier = ">=4.8.0,<4.15" }, + { name = "pymongo", marker = "extra == 'mongo'", specifier = ">=4.8.0,<4.16" }, { name = "pyodbc", marker = "extra == 'sql'", specifier = ">=5.2" }, { name = "qdrant-client", marker = "extra == 'qdrant'", specifier = "~=1.9" }, - { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = "~=6.0" }, + { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = ">=6,<8" }, { name = "redisvl", marker = "extra == 'redis'", specifier = "~=0.4" }, { name = "scipy", specifier = ">=1.15.1" }, { name = "sentence-transformers", marker = "extra == 'hugging-face'", specifier = ">=2.2,<6.0" }, @@ -6475,7 +6475,7 @@ provides-extras = ["anthropic", "autogen", "aws", "azure", "chroma", "copilotstu [package.metadata.requires-dev] dev = [ - { name = "ipykernel", specifier = "~=6.29" }, + { name = "ipykernel", specifier = ">=6.29,<8.0" }, { name = "mypy", specifier = ">=1.10" }, { name = "nbconvert", specifier = "~=7.16" }, { name = "pre-commit", specifier = "~=3.7" }, From 5dad51381af5919f34ae5bc51e3f7db79a906cd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:18:23 +0000 Subject: [PATCH 05/32] Python: Bump onnxruntime from 1.22.1 to 1.24.3 in /python (#13868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [onnxruntime](https://github.com/microsoft/onnxruntime) from 1.22.1 to 1.24.3.
Release notes

Sourced from onnxruntime's releases.

ONNX Runtime v1.24.3

This is a patch release for ONNX Runtime 1.24, containing bug fixes, security improvements, performance enhancements, and execution provider updates.

Security Fixes

  • Core: Fixed GatherCopyData integer truncation leading to heap out-of-bounds read/write. (#27444)
  • Core: Fixed RoiAlign heap out-of-bounds read via unchecked batch_indices. (#27543)
  • Core: Prevent heap OOB from maliciously crafted Lora Adapters. (#27518)
  • Core: Fixed out-of-bounds access for Resize operation. (#27419)

Bug Fixes

  • Core: Fixed GatherND division by zero when batch dimensions mismatch. (#27090)
  • Core: Fixed validation for external data paths for models loaded from bytes. (#27430)
  • Core: Fixed SkipLayerNorm fusion incorrectly applied when gamma/beta are not 1D. (#27459)
  • Core: Fixed double-free in TRT EP custom op domain Release functions. (#27471)
  • Core: Fixed QMoE CPU Operator. (#27360)
  • Core: Fixed MatmulNBits prepacking scales. (#27412)
  • Python: Fixed refcount bug in map input conversion that caused shutdown segfault. (#27413)
  • NuGet: Fixed DllImportResolver. (#27397)
  • NuGet: Added OrtEnv.DisableDllImportResolver to prevent fatal error on resolver conflict. (#27535)

Performance Improvements

  • Core: QMoE CPU performance update (up to 4x on 4-bit). (#27364)
  • Core: Fixed O(n²) model load time for TreeEnsemble with categorical feature chains. (#27391)

Execution Provider Updates

  • NvTensorRtRtx EP:
    • Avoid repetitive creation of fp4/fp8 native-custom-op domains. (#27192)
    • Added missing override specifiers to suppress warnings. (#27288)
    • DQ→MatMulNBits fusion transformer. (#27466)
  • WebGPU:
    • Used embedded WASM module in Blob URL workers when wasmBinary is provided. (#27318)
    • Fixed usage of wasmBinary together with a blob URL for .mjs. (#27411)
    • Removed the unhelpful "Unknown CPU vendor" warning. (#27399)
    • Allows new memory info name for WebGPU. (#27475)
  • MLAS:
    • Added DynamicQGemm function pointers and ukernel interface. (#27403)
    • Fixed error where bytes is not assigned for dynamic qgemm pack b size. (#27421)
  • VitisAI EP: Removed s_kernel_registry_vitisaiep.reset() in deinitialize_vitisai_ep(). (#27295)
  • Plugin EPs: Added "library_path" metadata entry to OrtEpDevice instances for plugin and provider bridge EPs. (#27522)

Build and Infrastructure

  • Pipelines:
    • Build Windows ARM64X binaries as part of packaging pipeline. (#27316)
    • Moved JAR testing pipelines to canonical pipeline template. (#27480)
  • Python: Enabled Python 3.14 CI and upgraded dependencies. (#27401)
  • Build: Suppressed spurious Array Out of Bounds warnings produced by GCC 14.2 compiler on Linux builds. (#27454)
  • Build: Fixed -Warray-bounds build error in MLAS on clang 17+. (#27499)
  • Telemetry: Added/Updated telemetry events. (#27356)
  • Config: Increased kMaxValueLength to 8192. (#27521)

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=onnxruntime&package-manager=pip&previous-version=1.22.1&new-version=1.24.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --- python/pyproject.toml | 5 ++- python/uv.lock | 88 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 1c2dd95d3038..7a339a7fd091 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -116,8 +116,9 @@ ollama = [ "ollama ~= 0.4" ] onnx = [ - # Pinning due to uv tag-resolution issues on macOS. - "onnxruntime==1.22.1", + # onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10. + "onnxruntime==1.22.1; python_version == '3.10'", + "onnxruntime>=1.24.3; python_version > '3.10'", "onnxruntime-genai==0.9.0" ] oracledb = [ diff --git a/python/uv.lock b/python/uv.lock index 9960d20fff57..26cd62a8f2ae 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -953,7 +953,8 @@ dependencies = [ { name = "kubernetes", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mmh3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "onnxruntime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "onnxruntime", version = "1.22.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1019,7 +1020,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "humanfriendly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "humanfriendly", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -2181,7 +2182,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -3738,13 +3739,18 @@ wheels = [ name = "onnxruntime" version = "1.22.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", +] dependencies = [ - { name = "coloredlogs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "flatbuffers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sympy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "coloredlogs", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "flatbuffers", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "protobuf", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "sympy", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/76/b9/664a1ffee62fa51529fac27b37409d5d28cadee8d97db806fcba68339b7e/onnxruntime-1.22.1-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:80e7f51da1f5201c1379b8d6ef6170505cd800e40da216290f5e06be01aadf95", size = 34319864, upload-time = "2025-07-10T19:15:15.371Z" }, @@ -3767,13 +3773,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/15/d75fd66aba116ce3732bb1050401394c5ec52074c4f7ee18db8838dd4667/onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7e823624b015ea879d976cbef8bfaed2f7e2cc233d7506860a76dd37f8f381", size = 16477261, upload-time = "2025-07-10T19:16:03.226Z" }, ] +[[package]] +name = "onnxruntime" +version = "1.24.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '4' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and python_full_version < '4' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version >= '4' and sys_platform == 'linux'", + "python_full_version >= '3.14' and python_full_version < '4' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version >= '4' and sys_platform == 'win32'", + "python_full_version >= '3.14' and python_full_version < '4' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", +] +dependencies = [ + { name = "flatbuffers", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "protobuf", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "sympy", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/3253db975a90c3ce1d475e2a230773a21cd7998537f0657947df6fb79861/onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c", size = 17332766, upload-time = "2026-03-05T17:18:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c5/3af6b325f1492d691b23844d88ed26844c1164620860c5efe95c0e22782d/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978", size = 15130330, upload-time = "2026-03-05T16:34:53.831Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/f96b46c1866a293ed23ca2cf5e5a63d413ad3a951da60dd877e3c56cbbca/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d", size = 17213247, upload-time = "2026-03-05T17:17:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/36/13/27cf4d8df2578747584e8758aeb0b673b60274048510257f1f084b15e80e/onnxruntime-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39", size = 12595530, upload-time = "2026-03-05T17:18:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/19/8c/6d9f31e6bae72a8079be12ed8ba36c4126a571fad38ded0a1b96f60f6896/onnxruntime-1.24.3-cp311-cp311-win_arm64.whl", hash = "sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723", size = 12261715, upload-time = "2026-03-05T17:18:39.699Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7f/dfdc4e52600fde4c02d59bfe98c4b057931c1114b701e175aee311a9bc11/onnxruntime-1.24.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91", size = 17342578, upload-time = "2026-03-05T17:19:02.403Z" }, + { url = "https://files.pythonhosted.org/packages/1c/dc/1f5489f7b21817d4ad352bf7a92a252bd5b438bcbaa7ad20ea50814edc79/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452", size = 15150105, upload-time = "2026-03-05T16:34:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/fd253da53594ab8efbefdc85b3638620ab1a6aab6eb7028a513c853559ce/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d", size = 17237101, upload-time = "2026-03-05T17:18:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/71/5f/eaabc5699eeed6a9188c5c055ac1948ae50138697a0428d562ac970d7db5/onnxruntime-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a", size = 12597638, upload-time = "2026-03-05T17:18:52.141Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5c/d8066c320b90610dbeb489a483b132c3b3879b2f93f949fb5d30cfa9b119/onnxruntime-1.24.3-cp312-cp312-win_arm64.whl", hash = "sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8", size = 12270943, upload-time = "2026-03-05T17:18:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/51/8d/487ece554119e2991242d4de55de7019ac6e47ee8dfafa69fcf41d37f8ed/onnxruntime-1.24.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:34a0ea5ff191d8420d9c1332355644148b1bf1a0d10c411af890a63a9f662aa7", size = 17342706, upload-time = "2026-03-05T16:35:10.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/25/8b444f463c1ac6106b889f6235c84f01eec001eaf689c3eff8c69cf48fae/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fd2ec7bb0fabe42f55e8337cfc9b1969d0d14622711aac73d69b4bd5abb5ed7", size = 15149956, upload-time = "2026-03-05T16:34:59.264Z" }, + { url = "https://files.pythonhosted.org/packages/34/fc/c9182a3e1ab46940dd4f30e61071f59eee8804c1f641f37ce6e173633fb6/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df8e70e732fe26346faaeec9147fa38bef35d232d2495d27e93dd221a2d473a9", size = 17237370, upload-time = "2026-03-05T17:18:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/3b549e1f4538514118bff98a1bcd6481dd9a17067f8c9af77151621c9a5c/onnxruntime-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:2d3706719be6ad41d38a2250998b1d87758a20f6ea4546962e21dc79f1f1fd2b", size = 12597939, upload-time = "2026-03-05T17:18:54.772Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/9696a5c4631a0caa75cc8bc4efd30938fd483694aa614898d087c3ee6d29/onnxruntime-1.24.3-cp313-cp313-win_arm64.whl", hash = "sha256:b082f3ba9519f0a1a1e754556bc7e635c7526ef81b98b3f78da4455d25f0437b", size = 12270705, upload-time = "2026-03-05T17:18:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/b7/65/a26c5e59e3b210852ee04248cf8843c81fe7d40d94cf95343b66efe7eec9/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72f956634bc2e4bd2e8b006bef111849bd42c42dea37bd0a4c728404fdaf4d34", size = 15161796, upload-time = "2026-03-05T16:35:02.871Z" }, + { url = "https://files.pythonhosted.org/packages/f3/25/2035b4aa2ccb5be6acf139397731ec507c5f09e199ab39d3262b22ffa1ac/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d1f25eed4ab9959db70a626ed50ee24cf497e60774f59f1207ac8556399c4d", size = 17240936, upload-time = "2026-03-05T17:18:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a4/b3240ea84b92a3efb83d49cc16c04a17ade1ab47a6a95c4866d15bf0ac35/onnxruntime-1.24.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a6b4bce87d96f78f0a9bf5cefab3303ae95d558c5bfea53d0bf7f9ea207880a8", size = 17344149, upload-time = "2026-03-05T16:35:13.382Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4a/4b56757e51a56265e8c56764d9c36d7b435045e05e3b8a38bedfc5aedba3/onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d48f36c87b25ab3b2b4c88826c96cf1399a5631e3c2c03cc27d6a1e5d6b18eb4", size = 15151571, upload-time = "2026-03-05T16:35:05.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/c6fb84980cec8f682a523fcac7c2bdd6b311e7f342c61ce48d3a9cb87fc6/onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e104d33a409bf6e3f30f0e8198ec2aaf8d445b8395490a80f6e6ad56da98e400", size = 17238951, upload-time = "2026-03-05T17:18:12.394Z" }, + { url = "https://files.pythonhosted.org/packages/57/14/447e1400165aca8caf35dabd46540eb943c92f3065927bb4d9bcbc91e221/onnxruntime-1.24.3-cp314-cp314-win_amd64.whl", hash = "sha256:e785d73fbd17421c2513b0bb09eb25d88fa22c8c10c3f5d6060589efa5537c5b", size = 12903820, upload-time = "2026-03-05T17:18:57.123Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/6b2fa5702e4bbba7339ca5787a9d056fc564a16079f8833cc6ba4798da1c/onnxruntime-1.24.3-cp314-cp314-win_arm64.whl", hash = "sha256:951e897a275f897a05ffbcaa615d98777882decaeb80c9216c68cdc62f849f53", size = 12594089, upload-time = "2026-03-05T17:18:47.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/dc/cd06cba3ddad92ceb17b914a8e8d49836c79e38936e26bde6e368b62c1fe/onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d4e70ce578aa214c74c7a7a9226bc8e229814db4a5b2d097333b81279ecde36", size = 15162789, upload-time = "2026-03-05T16:35:08.282Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d6/413e98ab666c6fb9e8be7d1c6eb3bd403b0bea1b8d42db066dab98c7df07/onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02aaf6ddfa784523b6873b4176a79d508e599efe12ab0ea1a3a6e7314408b7aa", size = 17240738, upload-time = "2026-03-05T17:18:15.203Z" }, +] + [[package]] name = "onnxruntime-genai" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "onnxruntime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "onnxruntime", version = "1.22.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d5/fd/7e26537155bba5a6498d93b9d72de2f70e6af50df8200f9b7fe346074769/onnxruntime_genai-0.9.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:18ffc8d3921d82578f33c05f7d123d0ddcac90ce92ce94d23226764e483f6609", size = 3249017, upload-time = "2025-08-06T17:32:06.804Z" }, @@ -6351,7 +6413,8 @@ ollama = [ { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] onnx = [ - { name = "onnxruntime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "onnxruntime", version = "1.22.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "onnxruntime-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] oracledb = [ @@ -6439,7 +6502,8 @@ requires-dist = [ { name = "numpy", marker = "python_full_version < '3.12'", specifier = ">=1.25.0" }, { name = "numpy", marker = "python_full_version >= '3.12'", specifier = ">=1.26.0" }, { name = "ollama", marker = "extra == 'ollama'", specifier = "~=0.4" }, - { name = "onnxruntime", marker = "extra == 'onnx'", specifier = "==1.22.1" }, + { name = "onnxruntime", marker = "python_full_version == '3.10.*' and extra == 'onnx'", specifier = "==1.22.1" }, + { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3" }, { name = "onnxruntime-genai", marker = "extra == 'onnx'", specifier = "==0.9.0" }, { name = "openai", specifier = ">=2.0.0" }, { name = "openapi-core", specifier = ">=0.18,<0.20" }, From 4fb5abcc661ed9cf0b6d3cdcae3566bad759373b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:18:31 +0000 Subject: [PATCH 06/32] Python: Bump nbconvert from 7.17.0 to 7.17.1 in /python (#13906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [nbconvert](https://github.com/jupyter/nbconvert) from 7.17.0 to 7.17.1.
Release notes

Sourced from nbconvert's releases.

v7.17.1

7.17.1

This is a security release, fixing two CVEs:

(full advisories will be published seven days after release, on 2026-04-14).

(Full Changelog)

Enhancements made

Bugs fixed

Maintenance and upkeep improvements

Contributors to this release

The following people contributed discussions, new ideas, code and documentation contributions, and review. See our definition of contributors.

(GitHub contributors page for this release)

@​akhmerov (activity) | @​bollwyvl (activity) | @​Carreau (activity) | @​ctcjab (activity) | @​davidbrochart (activity) | @​Ken-B (activity) | @​krassowski (activity) | @​mgeier (activity) | @​minrk (activity) | @​mpacer (activity) | @​MSeal (activity) | @​SylvainCorlay (activity) | @​takluyver (activity) | @​timkpaine (activity)

Changelog

Sourced from nbconvert's changelog.

7.17.1

This is a security release, fixing two CVEs:

(full advisories will be published seven days after release, on 2026-04-14).

(Full Changelog)

Enhancements made

Bugs fixed

Maintenance and upkeep improvements

Contributors to this release

The following people contributed discussions, new ideas, code and documentation contributions, and review. See our definition of contributors.

(GitHub contributors page for this release)

@​akhmerov (activity) | @​bollwyvl (activity) | @​Carreau (activity) | @​ctcjab (activity) | @​davidbrochart (activity) | @​Ken-B (activity) | @​krassowski (activity) | @​mgeier (activity) | @​minrk (activity) | @​mpacer (activity) | @​MSeal (activity) | @​SylvainCorlay (activity) | @​takluyver (activity) | @​timkpaine (activity)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nbconvert&package-manager=uv&previous-version=7.17.0&new-version=7.17.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/semantic-kernel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 26cd62a8f2ae..9bca5c34868b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -3459,7 +3459,7 @@ wheels = [ [[package]] name = "nbconvert" -version = "7.17.0" +version = "7.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3477,9 +3477,9 @@ dependencies = [ { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "traitlets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, ] [[package]] From 293ec8f85071603fc3a7ac6a83ad9b12e25022f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:57:59 +0900 Subject: [PATCH 07/32] Python: Bump python-multipart from 0.0.22 to 0.0.26 in /python (#13878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.22 to 0.0.26.
Release notes

Sourced from python-multipart's releases.

Version 0.0.26

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.25...0.0.26

Version 0.0.25

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.24...0.0.25

Version 0.0.24

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.23...0.0.24

Version 0.0.23

What's Changed

New Contributors

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.22...0.0.23

Changelog

Sourced from python-multipart's changelog.

0.0.26 (2026-04-10)

  • Skip preamble before the first multipart boundary more efficiently #262.
  • Silently discard epilogue data after the closing multipart boundary #259.

0.0.25 (2026-04-10)

  • Add MIME content type info to File #143.
  • Handle CTE values case-insensitively #258.
  • Remove custom FormParser classes #257.
  • Add UPLOAD_DELETE_TMP to FormParser config #254.
  • Emit field_end for trailing bare field names on finalize #230.
  • Handle multipart headers case-insensitively #252.
  • Apply Apache-2.0 properly #247.

0.0.24 (2026-04-05)

  • Validate chunk_size in parse_form() #244.

0.0.23 (2026-04-05)

  • Remove unused trust_x_headers parameter and X-File-Name fallback #196.
  • Return processed length from QuerystringParser._internal_write #229.
  • Cleanup metadata dunders from __init__.py #227.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python-multipart&package-manager=uv&previous-version=0.0.22&new-version=0.0.26)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/semantic-kernel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 9bca5c34868b..8869e088171d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -5433,11 +5433,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, ] [[package]] From ff3a1c6c6eaececd3a77fa747aa2b701ec92f35f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:58:05 +0900 Subject: [PATCH 08/32] Python: Update boto3 requirement from <1.41.0,>=1.36.4 to >=1.36.4,<1.43.0 in /python (#13867) Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
Commits
  • 54a9dc1 Merge branch 'release-1.42.89'
  • 730d84c Bumping version to 1.42.89
  • 6b65768 Add changelog entries from botocore
  • f92a06c Merge branch 'release-1.42.88'
  • 5da0f6a Merge branch 'release-1.42.88' into develop
  • 9bdec29 Bumping version to 1.42.88
  • d880788 Add changelog entries from botocore
  • 39a4122 chore: add additional text to CONTRIBUTING.rst (#4749)
  • 8d65320 Merge branch 'release-1.42.87'
  • fdcbb88 Merge branch 'release-1.42.87' into develop
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 7a339a7fd091..e396c4d4816c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -68,7 +68,7 @@ autogen = [ "autogen-agentchat >= 0.2, <0.4" ] aws = [ - "boto3>=1.36.4,<1.41.0", + "boto3>=1.36.4,<1.43.0", ] azure = [ "azure-ai-inference >= 1.0.0b6", From 479f9443ee6e7146010314b7c70dd51ccc754cf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:58:38 +0000 Subject: [PATCH 09/32] Python: Bump google-cloud-aiplatform from 1.114.0 to 1.133.0 in /python (#13577) Bumps [google-cloud-aiplatform](https://github.com/googleapis/python-aiplatform) from 1.114.0 to 1.133.0.
Release notes

Sourced from google-cloud-aiplatform's releases.

v1.133.0

1.133.0 (2026-01-08)

Features

  • Deprecate tuning public preview SDK in favor of tuning SDK (35d362c)
  • GenAI SDK client - Enabling Few-shot Prompt Optimization by passing either "OPTIMIZATION_TARGET_FEW_SHOT_RUBRICS" or "OPTIMIZATION_TARGET_FEW_SHOT_TARGET_RESPONSE" to the optimize_prompt method (715cc5b)
  • GenAI SDK client(memory): Add enable_third_person_memories (65717fa)
  • Support Developer Connect in AE (04f1771)

Bug Fixes

  • Add None check for agent_info in evals.py (c8c0f0f)
  • GenAI client(evals) - Fix TypeError in _build_generate_content_config (be2eaaa)
  • Make project_number to project_id mapping fail-open. (f1c8458)
  • Replace asyncio.run with create_task in ADK async thread mains. (83f4076)
  • Replace asyncio.run with create_task in ADK async thread mains. (8c876ef)
  • Require uri or staging bucket configuration for saving model to Vertex Experiment. (5448f06)
  • Return embedding metadata if available (d9c6eb1)
  • Update examples_dataframe type to PandasDataFrame in Prompt Optimizer. (a2564cc)

v1.132.0

1.132.0 (2025-12-17)

Features

  • Add Lustre support to the Vertex Training Custom Job API (71747e8)

Documentation

  • A comment for field restart_job_on_worker_restart in message .google.cloud.aiplatform.v1beta1.Scheduling is changed (71747e8)
  • A comment for field timeout in message .google.cloud.aiplatform.v1beta1.Scheduling is changed (71747e8)

v1.131.0

1.131.0 (2025-12-16)

Features

  • Allow list of events to be passed to AdkApp.async_stream_query (dd8840a)
  • GenAI Client(evals) - Support CustomCodeExecution metric in Vertex Gen AI Eval Service (4114728)
  • Updates the ADK template to direct structured JSON logs to standard output. (a65ec29)

Bug Fixes

... (truncated)

Changelog

Sourced from google-cloud-aiplatform's changelog.

1.133.0 (2026-01-08)

Features

  • Deprecate tuning public preview SDK in favor of tuning SDK (35d362c)
  • GenAI SDK client - Enabling Few-shot Prompt Optimization by passing either "OPTIMIZATION_TARGET_FEW_SHOT_RUBRICS" or "OPTIMIZATION_TARGET_FEW_SHOT_TARGET_RESPONSE" to the optimize_prompt method (715cc5b)
  • GenAI SDK client(memory): Add enable_third_person_memories (65717fa)
  • Support Developer Connect in AE (04f1771)

Bug Fixes

  • Add None check for agent_info in evals.py (c8c0f0f)
  • GenAI client(evals) - Fix TypeError in _build_generate_content_config (be2eaaa)
  • Make project_number to project_id mapping fail-open. (f1c8458)
  • Replace asyncio.run with create_task in ADK async thread mains. (83f4076)
  • Replace asyncio.run with create_task in ADK async thread mains. (8c876ef)
  • Require uri or staging bucket configuration for saving model to Vertex Experiment. (5448f06)
  • Return embedding metadata if available (d9c6eb1)
  • Update examples_dataframe type to PandasDataFrame in Prompt Optimizer. (a2564cc)

1.132.0 (2025-12-17)

Features

  • Add Lustre support to the Vertex Training Custom Job API (71747e8)
  • Add Lustre support to the Vertex Training Custom Job API (71747e8)

Documentation

  • A comment for field restart_job_on_worker_restart in message .google.cloud.aiplatform.v1beta1.Scheduling is changed (71747e8)
  • A comment for field timeout in message .google.cloud.aiplatform.v1beta1.Scheduling is changed (71747e8)

1.131.0 (2025-12-16)

Features

  • Allow list of events to be passed to AdkApp.async_stream_query (dd8840a)
  • GenAI Client(evals) - Support CustomCodeExecution metric in Vertex Gen AI Eval Service (4114728)
  • Updates the ADK template to direct structured JSON logs to standard output. (a65ec29)

Bug Fixes

  • Fix RagManagedVertexVectorSearch when using backend_config (df0976e)
  • GenAI Client(evals) - patch for vulnerability in visualization (8a00d43)

... (truncated)

Commits
  • 78f2bdd chore(main): release 1.133.0 (#6211)
  • c8c0f0f fix: Add None check for agent_info in evals.py
  • 9952b97 chore: rollback
  • 83f4076 fix: Replace asyncio.run with create_task in ADK async thread mains.
  • 937d5af Copybara import of the project:
  • aaaf902 chore: bump google-auth lower bound to 2.47.0 in GenAI and Vertex SDKs
  • 8c876ef fix: Replace asyncio.run with create_task in ADK async thread mains.
  • 5448f06 fix: Require uri or staging bucket configuration for saving model to Vertex E...
  • 65717fa feat: GenAI SDK client(memory): Add enable_third_person_memories
  • be2eaaa fix: GenAI client(evals) - Fix TypeError in _build_generate_content_config
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google-cloud-aiplatform&package-manager=uv&previous-version=1.114.0&new-version=1.133.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/semantic-kernel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/pyproject.toml | 2 +- python/uv.lock | 76 +++---------------------------------------- 2 files changed, 5 insertions(+), 73 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index e396c4d4816c..00a73f4ab9c1 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -87,7 +87,7 @@ faiss = [ "faiss-cpu>=1.10.0" ] google = [ - "google-cloud-aiplatform ~= 1.114.0", + "google-cloud-aiplatform>=1.114,<1.134", "google-genai ~= 1.51.0" ] hugging_face = [ diff --git a/python/uv.lock b/python/uv.lock index 8869e088171d..430794738a60 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1612,7 +1612,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.114.0" +version = "1.133.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1626,12 +1626,11 @@ dependencies = [ { name = "proto-plus", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "shapely", 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/3d/0e/8097231fba8e688993b0b6d371ee298ac3955cdca77fc0731799de1253ca/google_cloud_aiplatform-1.114.0.tar.gz", hash = "sha256:44e5e3da9b23c9316a4d9e7cd6a04258ebf84f3aadf95a725d5d1de179e2c2ce", size = 9650673, upload-time = "2025-09-16T19:47:55.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/be/31ce7fd658ddebafbe5583977ddee536b2bacc491ad10b5a067388aec66f/google_cloud_aiplatform-1.133.0.tar.gz", hash = "sha256:3a6540711956dd178daaab3c2c05db476e46d94ac25912b8cf4f59b00b058ae0", size = 9921309, upload-time = "2026-01-08T22:11:25.079Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/0a/526e70e5cd8e0e96207e201721457dac020d9b8d1bd2ce7326e550b8150d/google_cloud_aiplatform-1.114.0-py2.py3-none-any.whl", hash = "sha256:87386d9364bd0bed4dd33873845afbbe251d1ed83ee25d676c3c0cea630af682", size = 8032171, upload-time = "2025-09-16T19:47:52.725Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/ef74ff65aebb74eaba51078e33ddd897247ba0d1197fd5a7953126205519/google_cloud_aiplatform-1.133.0-py2.py3-none-any.whl", hash = "sha256:dfc81228e987ca10d1c32c7204e2131b3c8d6b7c8e0b4e23bf7c56816bc4c566", size = 8184595, upload-time = "2026-01-08T22:11:22.067Z" }, ] [[package]] @@ -6487,7 +6486,7 @@ requires-dist = [ { name = "cloudevents", specifier = "~=1.0" }, { name = "defusedxml", specifier = "~=0.7" }, { name = "faiss-cpu", marker = "extra == 'faiss'", specifier = ">=1.10.0" }, - { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = "~=1.114.0" }, + { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = ">=1.114,<1.134" }, { name = "google-genai", marker = "extra == 'google'", specifier = "~=1.51.0" }, { name = "ipykernel", marker = "extra == 'notebooks'", specifier = ">=6.29,<8.0" }, { name = "jinja2", specifier = "~=3.1" }, @@ -6583,73 +6582,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, - { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, - { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, - { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, - { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, - { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, - { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, - { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, - { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, - { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, - { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, - { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, - { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, - { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, - { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, - { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, - { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, - { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" From 6cc918e87f14f2960cf0e3ad8444adb5dc890c89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:27:57 +0000 Subject: [PATCH 10/32] Update google-genai requirement from ~=1.51.0 to >=1.51,<1.75 in /python (#13865) Updates the requirements on [google-genai](https://github.com/googleapis/python-genai) to permit the latest version.
Release notes

Sourced from google-genai's releases.

v1.74.0

1.74.0 (2026-04-29)

Features

  • [Interactions] Add FileCitation.{custom_metadata,media_id,page_number} (aed41ec)
  • Add output_info to BatchJob (7b77ab8)
  • Add gemini-3.1-flash-tts-preview model to options (8bdc1c3)
  • Add ImageResizeMode for GenerateVideos (317d2af)
  • Add new Gemini Deep Research agent models (16fffbd)
  • Add one_of support to JSONSchema for Agent Platform (8c00c52)
  • Add Vertex Dataset input and output options for batch jobs (d880f92)
  • interaction-api: Add grounding tool usage breakdown to Interaction Usage. (b24fb5a)
  • Introduce enterprise to Client constructor and GOOGLE_GENAI_USE_ENTERPRISE (693fd9a)
  • Replace the more ambiguous rate field with sample_rate. (88d9b4a)

Bug Fixes

  • Catch google-auth wrapped errors (48ac850)
  • Removing Python 3.9 support due to EOL (8bc2b10)
  • retry: Retry on httpx.TimeoutException with HttpRetryOptions (#2345) (0598bab)
  • Streaming method doesn't handle multi-line SSE (f8a2e7e)
  • Typing in AsyncClient.__aexit__, __exit__. (a74dc65)

Documentation

  • Add instruction for custom endpoint (dd79904)
  • Fix broken link for rate limits (d22ea99)
  • Regenerate docs for 1.73.1 (2fb714b)
  • Remove duplicate line in genai client docstring. (b1c6026)
  • Replace Vertex AI with Gemini Enterprise Agent Platform (7c1ecd5)
  • Update doc string to replace Vertex AI with Gemini Enterprise Agent Platform, update method error message to replace Vertex AI with Gemini Enterprise Agent Platform (previously known as Vertex AI), update converter error message to replace Vertex AI with Gemini Enterprise Agent Platform (413f0f9)
  • Update Gemini Enterprise Agent Platform home page url (b02cb95)
  • Update README.md with correct Pydantic link (fa97cc6)
  • Update README.md with correct Pydantic link (fa97cc6)
Changelog

Sourced from google-genai's changelog.

1.74.0 (2026-04-29)

Features

  • [Interactions] Add FileCitation.{custom_metadata,media_id,page_number} (aed41ec)
  • Add output_info to BatchJob (7b77ab8)
  • Add gemini-3.1-flash-tts-preview model to options (8bdc1c3)
  • Add ImageResizeMode for GenerateVideos (317d2af)
  • Add new Gemini Deep Research agent models (16fffbd)
  • Add one_of support to JSONSchema for Agent Platform (8c00c52)
  • Add Vertex Dataset input and output options for batch jobs (d880f92)
  • interaction-api: Add grounding tool usage breakdown to Interaction Usage. (b24fb5a)
  • Introduce enterprise to Client constructor and GOOGLE_GENAI_USE_ENTERPRISE (693fd9a)
  • Replace the more ambiguous rate field with sample_rate. (88d9b4a)

Bug Fixes

  • Catch google-auth wrapped errors (48ac850)
  • Removing Python 3.9 support due to EOL (8bc2b10)
  • retry: Retry on httpx.TimeoutException with HttpRetryOptions (#2345) (0598bab)
  • Streaming method doesn't handle multi-line SSE (f8a2e7e)
  • Typing in AsyncClient.__aexit__, __exit__. (a74dc65)

Documentation

  • Add instruction for custom endpoint (dd79904)
  • Fix broken link for rate limits (d22ea99)
  • Regenerate docs for 1.73.1 (2fb714b)
  • Remove duplicate line in genai client docstring. (b1c6026)
  • Replace Vertex AI with Gemini Enterprise Agent Platform (7c1ecd5)
  • Update doc string to replace Vertex AI with Gemini Enterprise Agent Platform, update method error message to replace Vertex AI with Gemini Enterprise Agent Platform (previously known as Vertex AI), update converter error message to replace Vertex AI with Gemini Enterprise Agent Platform (413f0f9)
  • Update Gemini Enterprise Agent Platform home page url (b02cb95)
  • Update README.md with correct Pydantic link (fa97cc6)
  • Update README.md with correct Pydantic link (fa97cc6)

1.73.1 (2026-04-14)

Bug Fixes

  • Refactor Webhook types in GenAI SDKs for easier useage (3f36ca1)
  • Rename webhooks.retrieve to webhooks.get. (649f4b0)

Documentation

  • Update python docs for 1.73.0 (acd3767)

... (truncated)

Commits
  • 860c041 chore(main): release 1.74.0 (#2285)
  • abcaa0d chore: Fix usage examples of LocalTokenizer.
  • b7e8771 chore: Add page number
  • 841301e chore: Adjust Webhook update to better reflect modifiable fields
  • b24fb5a feat(interaction-api): Add grounding tool usage breakdown to Interaction Usage.
  • b1c6026 docs: Remove duplicate line in genai client docstring.
  • 0598bab fix(retry): retry on httpx.TimeoutException with HttpRetryOptions (#2345)
  • f8a2e7e fix: Streaming method doesn't handle multi-line SSE
  • 8bc2b10 fix: Removing Python 3.9 support due to EOL
  • ab5e328 chore: internal change
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 00a73f4ab9c1..565b11375109 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -88,7 +88,7 @@ faiss = [ ] google = [ "google-cloud-aiplatform>=1.114,<1.134", - "google-genai ~= 1.51.0" + "google-genai >= 1.51,< 1.75" ] hugging_face = [ "transformers[torch] ~= 4.28", From 4e4b9456b8e5ca812b93664908358494fac31243 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:20:48 +0900 Subject: [PATCH 11/32] Python: Update pydantic requirement from !=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.13,>=2.0 to >=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.14 in /python (#13864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [pydantic](https://github.com/pydantic/pydantic) to permit the latest version.
Release notes

Sourced from pydantic's releases.

v2.13.0 2026-04-13

v2.13.0 (2026-04-13)

The highlights of the v2.13 release are available in the blog post. Several minor changes (considered non-breaking changes according to our versioning policy) are also included in this release. Make sure to look into them before upgrading.

This release contains the updated pydantic.v1 namespace, matching version 1.10.26 which includes support for Python 3.14.

What's Changed

See the beta releases for all changes sinces 2.12.

Packaging

  • Add zizmor for GitHub Actions workflow linting by @​Viicos in #13039
  • Update jiter to v0.14.0 to fix a segmentation fault on musl Linux by @​Viicos in #13064

New Features

  • Allow default factories of private attributes to take validated model data by @​Viicos in #13013

Changes

Fixes

  • Change type of Any when synthesizing _build_sources for BaseSettings.__init__() signature in the mypy plugin by @​Viicos in #13049
  • Fix model equality when using runtime extra configuration by @​Viicos in #13062

New Contributors

Full Changelog: https://github.com/pydantic/pydantic/compare/v2.12.0...v2.13.0

Changelog

Sourced from pydantic's changelog.

v2.13.0 (2026-04-13)

GitHub release

The highlights of the v2.13 release are available in the blog post. Several minor changes (considered non-breaking changes according to our versioning policy) are also included in this release. Make sure to look into them before upgrading.

This release contains the updated pydantic.v1 namespace, matching version 1.10.26 which includes support for Python 3.14.

What's Changed

See the beta releases for all changes sinces 2.12.

New Features

  • Allow default factories of private attributes to take validated model data by @​Viicos in #13013

Changes

Fixes

  • Change type of Any when synthesizing _build_sources for BaseSettings.__init__() signature in the mypy plugin by @​Viicos in #13049
  • Fix model equality when using runtime extra configuration by @​Viicos in #13062

Packaging

  • Add zizmor for GitHub Actions workflow linting by @​Viicos in #13039
  • Update jiter to v0.14.0 to fix a segmentation fault on musl Linux by @​Viicos in #13064

New Contributors

v2.13.0b3 (2026-03-31)

GitHub release

What's Changed

New Features

Changes

... (truncated)

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 565b11375109..c3044451cb76 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "azure-ai-agents >= 1.2.0b3", "aiohttp ~= 3.8", "cloudevents ~=1.0", - "pydantic >=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.13", + "pydantic >=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.14", "pydantic-settings ~= 2.0", "defusedxml ~= 0.7", # azure identity From a68600a73a8153c436bffe5a96c4508cf0c09238 Mon Sep 17 00:00:00 2001 From: Barton Milnor Mallory <205405648+erhebend-tai@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:18:08 -0500 Subject: [PATCH 12/32] .Net: Update Step04_AzureAIAgent_CodeInterpreter.cs (#13886) ### Motivation and Context ### Description ### Contribution Checklist - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> --- .../AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs index ca7558cd6a81..6446dbde6070 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs @@ -27,7 +27,7 @@ public async Task UseCodeInterpreterToolWithAgent() // Respond to user input try { - await InvokeAgentAsync("Use code to determine the values in the Fibonacci sequence that that are less then the value of 101?"); + await InvokeAgentAsync("Use code to determine the values in the Fibonacci sequence that are less than the value of 101?"); } finally { From 1369c2e0ce6b9e5e86877b62c14b7311440aed2c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:36:51 +0100 Subject: [PATCH 13/32] .Net: Add ExtraBody to OpenAIPromptExecutionSettings (#12307) (#13934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation and Context Closes #12307. Provides .NET parity with the Python fix in #11852 by adding an escape-hatch `ExtraBody` property to `OpenAIPromptExecutionSettings`. This allows callers to inject vendor-specific or preview parameters into the request body without waiting for SDK modeling. Use cases from the issue include: - Qwen3 open-source models requiring `enable_thinking: false` for non-streaming calls - ChatGLM thinking-mode flags - Any OpenAI-compatible endpoint exposing fields not modeled by the SDK ### Description Adds `IDictionary? ExtraBody` to `OpenAIPromptExecutionSettings` (inherited automatically by `AzureOpenAIPromptExecutionSettings`), marked `[Experimental("SKEXP0010")]`. #### Hybrid key syntax (diverges intentionally from Python to leverage the OpenAI .NET SDK's `JsonPatch`) - A plain key is treated as a literal top-level field name. Keys with dots, brackets, or other special characters are bracket-quoted automatically and remain literal. - `ExtraBody["enable_thinking"] = false` → `{ "enable_thinking": false }` - `ExtraBody["weird.key"] = "x"` → `{ "weird.key": "x" }` - A key starting with `$.` is interpreted as a JSONPath expression and applied as a deep patch onto the request body. - `ExtraBody["$.thinking.enabled"] = false` → `{ "thinking": { "enabled": false } }` - `ExtraBody["$.tools[0].function.name"] = "x"` → patches into array elements - Nested dictionaries are also supported via plain keys: `ExtraBody["thinking"] = new Dictionary { ["enabled"] = false }`. #### Behavior - **Last-write-wins on collisions** with strongly-typed properties (e.g. `Temperature`). Documented. - **`null` values emit JSON `null`**. Field removal is not supported. - **No input validation, no telemetry emission** of `ExtraBody` contents (Python parity). - **Shallow `Clone()`**, `ReadOnlyDictionary` on `Freeze()`. #### Wiring - Classic path: `ClientCore.ChatCompletion.cs` and `AzureClientCore.ChatCompletion.cs` apply patches at the end of `CreateChatCompletionOptions`. - `IChatClient` path: new `protected virtual PrepareChatOptionsForRequest(ChatOptions)` hook on `PromptExecutionSettings` (mirrors the existing `PrepareChatHistoryForRequest` pattern). The OpenAI override sets `ChatOptions.RawRepresentationFactory` and removes the redundant `AdditionalProperties["extra_body"]` entry produced by `ToChatOptions`'s round-trip. #### Files changed - `SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs` — new hook + internal bridge - `SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs` — invokes the hook on the original derived instance - `Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs` — property, Freeze, Clone, `PrepareChatOptionsForRequest` override, `ApplyExtraBody`/`ApplyExtraBodyEntry` helpers - `Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs` — apply in classic path - `Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs` — apply in Azure classic path - `samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs` — concept sample (flat field + deep patch) #### Tests (14 new, all passing) `Connectors.OpenAI.UnitTests` (11): non-streaming flat key, streaming flat key, collision (last-write-wins), nested dict, `$.` deep patch, literal dotted key, `null` value, `FromExecutionSettings` round-trip, Clone shallow, Freeze read-only, `ToChatOptions` `RawRepresentationFactory` + `AdditionalProperties` cleanup. `Connectors.AzureOpenAI.UnitTests` (3): flat key, collision, `$.` deep patch. #### Regression - `Connectors.OpenAI.UnitTests`: 485 passed - `Connectors.AzureOpenAI.UnitTests`: 493 passed - `SemanticKernel.UnitTests`: 1606 passed (verifies the new hook in Abstractions has no side effects) ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the SK Contribution Guidelines and the pre-submission formatting script raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄 --- .../OpenAI_ChatCompletionExtraBody.cs | 79 ++++ ...AzureOpenAIChatCompletionExtraBodyTests.cs | 121 ++++++ .../Core/AzureClientCore.ChatCompletion.cs | 2 + .../OpenAIChatCompletionExtraBodyTests.cs | 355 ++++++++++++++++++ .../Core/ClientCore.ChatCompletion.cs | 2 + .../Settings/OpenAIPromptExecutionSettings.cs | 123 ++++++ .../AI/PromptExecutionSettings.cs | 16 + .../AI/PromptExecutionSettingsExtensions.cs | 15 +- 8 files changed, 709 insertions(+), 4 deletions(-) create mode 100644 dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs create mode 100644 dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionExtraBodyTests.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs diff --git a/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs b/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs new file mode 100644 index 000000000000..3d790de41b65 --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace ChatCompletion; + +#pragma warning disable SKEXP0010 // OpenAIPromptExecutionSettings.ExtraBody is experimental. + +/// +/// is an escape hatch that injects additional fields +/// into the request body sent to the OpenAI-compatible endpoint. Use it to pass vendor-specific or preview +/// parameters that are not modeled by (for example, +/// Qwen3's enable_thinking flag, ChatGLM thinking modes, or NVIDIA NIM custom knobs). +/// +/// Key syntax: +/// +/// A plain key (without leading $.) is treated as a literal top-level field name. +/// A key starting with $. is interpreted as a JSONPath expression and applied as a deep patch onto the request body. +/// +/// +public class OpenAI_ChatCompletionExtraBody(ITestOutputHelper output) : BaseTest(output) +{ + /// + /// Adds a flat top-level field to the outgoing chat completion request: + /// { "messages": [...], "model": "qwen-plus", "enable_thinking": false, ... } + /// + [Fact] + public async Task FlatFieldExampleAsync() + { + OpenAIChatCompletionService chatCompletionService = new( + TestConfiguration.OpenAI.ChatModelId, + TestConfiguration.OpenAI.ApiKey); + + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + // Required by Qwen3 open-source models for non-streaming calls. + ["enable_thinking"] = false, + }, + }; + + var chatHistory = new ChatHistory("You are a helpful assistant."); + chatHistory.AddUserMessage("Who are you?"); + + var reply = await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings); + + Console.WriteLine(reply.Content); + } + + /// + /// Uses a $.-prefixed JSONPath key to surgically patch a nested field in the request body + /// (for example, into thinking.enabled). This avoids having to specify the entire nested object. + /// + [Fact] + public async Task DeepPatchExampleAsync() + { + OpenAIChatCompletionService chatCompletionService = new( + TestConfiguration.OpenAI.ChatModelId, + TestConfiguration.OpenAI.ApiKey); + + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + // Emits { "thinking": { "enabled": false } } in the request body. + ["$.thinking.enabled"] = false, + }, + }; + + var chatHistory = new ChatHistory("You are a helpful assistant."); + chatHistory.AddUserMessage("Summarize the plot of Hamlet in one sentence."); + + var reply = await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings); + + Console.WriteLine(reply.Content); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionExtraBodyTests.cs new file mode 100644 index 000000000000..60410fc79885 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionExtraBodyTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Services; + +/// +/// Unit tests verifying that is honored by +/// the classic path. +/// +public sealed class AzureOpenAIChatCompletionExtraBodyTests : IDisposable +{ + private readonly MultipleHttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly ChatHistory _chatHistory = [new ChatMessageContent(AuthorRole.User, "test")]; + + public AzureOpenAIChatCompletionExtraBodyTests() + { + this._messageHandlerStub = new MultipleHttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + [Fact] + public async Task ExtraBodyFlatKeyAppearsAtTopLevelAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")), + }); + var settings = new AzureOpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContents[0]!); + Assert.True(body.TryGetProperty("enable_thinking", out var value)); + Assert.False(value.GetBoolean()); + } + + [Fact] + public async Task ExtraBodyOverridesFirstClassPropertyAsync() + { + // Arrange - last-write-wins. + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")), + }); + var settings = new AzureOpenAIPromptExecutionSettings + { + Temperature = 0.7, + ExtraBody = new Dictionary + { + ["temperature"] = 0.0, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContents[0]!); + Assert.Equal(0.0, body.GetProperty("temperature").GetDouble()); + } + + [Fact] + public async Task ExtraBodyJsonPathPrefixDoesDeepPatchAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")), + }); + var settings = new AzureOpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["$.thinking.enabled"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContents[0]!); + Assert.False(body.GetProperty("thinking").GetProperty("enabled").GetBoolean()); + } + + private static JsonElement ParseRequestBody(byte[] requestContent) + { + var json = Encoding.UTF8.GetString(requestContent); + return JsonDocument.Parse(json).RootElement; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs index cfb6df60ce22..ae4cd0494916 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs @@ -131,6 +131,8 @@ protected override ChatCompletionOptions CreateChatCompletionOptions( } } + OpenAIPromptExecutionSettings.ApplyExtraBody(options, executionSettings); + return options; } diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs new file mode 100644 index 000000000000..95f0b409ed0b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; +using ChatMessageContent = Microsoft.SemanticKernel.ChatMessageContent; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; + +/// +/// Unit tests for the property and its application to +/// outgoing chat completion requests. +/// +public sealed class OpenAIChatCompletionExtraBodyTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly ChatHistory _chatHistory = [new ChatMessageContent(AuthorRole.User, "test")]; + + public OpenAIChatCompletionExtraBodyTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub + { + ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ChatCompletionResponse), + }, + }; + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + [Fact] + public async Task ExtraBodyFlatKeyAppearsAtTopLevelAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("enable_thinking", out var value)); + Assert.False(value.GetBoolean()); + } + + [Fact] + public async Task ExtraBodyStreamingFlatKeyAppearsAtTopLevelAsync() + { + // Arrange + var streamBytes = Encoding.UTF8.GetBytes(ChatCompletionStreamingResponse); + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new System.IO.MemoryStream(streamBytes)), + }; + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + + // Act + await foreach (var _ in service.GetStreamingChatMessageContentsAsync(this._chatHistory, settings)) + { + } + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("enable_thinking", out var value)); + Assert.False(value.GetBoolean()); + } + + [Fact] + public async Task ExtraBodyOverridesFirstClassPropertyAsync() + { + // Arrange - last-write-wins; ExtraBody value applied via JsonPatch overrides the strongly-typed property. + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + Temperature = 0.7, + ExtraBody = new Dictionary + { + ["temperature"] = 0.0, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.Equal(0.0, body.GetProperty("temperature").GetDouble()); + } + + [Fact] + public async Task ExtraBodyNestedDictionaryEmitsNestedJsonObjectAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["thinking"] = new Dictionary { ["enabled"] = false, ["budget"] = 100 }, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + var thinking = body.GetProperty("thinking"); + Assert.False(thinking.GetProperty("enabled").GetBoolean()); + Assert.Equal(100, thinking.GetProperty("budget").GetInt32()); + } + + [Fact] + public async Task ExtraBodyJsonPathPrefixDoesDeepPatchAsync() + { + // Arrange - $.-prefixed key is interpreted as a JSONPath patch and creates the nested structure. + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["$.thinking.enabled"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.False(body.GetProperty("thinking").GetProperty("enabled").GetBoolean()); + } + + [Fact] + public async Task ExtraBodyLiteralDottedKeyEmitsLiteralFieldAsync() + { + // Arrange - keys without $. prefix are literal, even when they contain dots. + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["weird.key"] = "literal-value", + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("weird.key", out var value)); + Assert.Equal("literal-value", value.GetString()); + } + + [Fact] + public async Task ExtraBodyNullValueEmitsJsonNullAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["nullable_field"] = null, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("nullable_field", out var value)); + Assert.Equal(JsonValueKind.Null, value.ValueKind); + } + + [Fact] + public void FromExecutionSettingsRoundTripPreservesExtraBody() + { + // Arrange - deserializing through the base type (e.g. via PromptTemplateConfig) should preserve extra_body. + var jsonSettings = new PromptExecutionSettings + { + ExtensionData = new Dictionary + { + ["temperature"] = 0.5, + ["extra_body"] = new Dictionary { ["enable_thinking"] = false }, + }, + }; + + // Act + var settings = OpenAIPromptExecutionSettings.FromExecutionSettings(jsonSettings); + + // Assert + Assert.NotNull(settings.ExtraBody); + Assert.True(settings.ExtraBody.ContainsKey("enable_thinking")); + } + + [Fact] + public void CloneCopiesExtraBodyShallowly() + { + // Arrange + var original = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary { ["a"] = 1 }, + }; + + // Act + var clone = (OpenAIPromptExecutionSettings)original.Clone(); + clone.ExtraBody!["b"] = 2; + + // Assert - top-level shallow copy: adding to clone does not affect original. + Assert.False(original.ExtraBody!.ContainsKey("b")); + Assert.True(clone.ExtraBody.ContainsKey("a")); + Assert.True(clone.ExtraBody.ContainsKey("b")); + } + + [Fact] + public void FreezeMakesExtraBodyReadOnly() + { + // Arrange + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary { ["a"] = 1 }, + }; + + // Act + settings.Freeze(); + + // Assert + Assert.Throws(() => settings.ExtraBody!["b"] = 2); + } + + [Fact] + public void ToChatOptionsSetsRawRepresentationFactoryAndCleansAdditionalProperties() + { + // Arrange - exercises the IChatClient path: PrepareChatOptionsForRequest must + // wire ChatOptions.RawRepresentationFactory and remove the redundant extra_body entry from AdditionalProperties. + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + ["$.thinking.enabled"] = false, + }, + }; + + // Act + var chatOptions = settings.ToChatOptions(kernel: null); + + // Assert + Assert.NotNull(chatOptions); + Assert.NotNull(chatOptions!.RawRepresentationFactory); + Assert.False(chatOptions.AdditionalProperties?.ContainsKey("extra_body") ?? false); + + // Verify the factory produces a ChatCompletionOptions that serializes the patched fields. + var rawObj = chatOptions.RawRepresentationFactory.Invoke(null!); + var raw = Assert.IsType(rawObj); + var serialized = global::System.ClientModel.Primitives.ModelReaderWriter.Write(raw).ToString(); + var json = JsonDocument.Parse(serialized).RootElement; + Assert.False(json.GetProperty("enable_thinking").GetBoolean()); + Assert.False(json.GetProperty("thinking").GetProperty("enabled").GetBoolean()); + } + + [Fact] + public async Task IChatClientPathPreservesStronglyTypedSettingsAlongsideExtraBodyAsync() + { + // Arrange - end-to-end IChatClient path: build an IChatClient via OpenAI SDK and send a request + // through M.E.AI. Verify both the strongly-typed property (Temperature) and the ExtraBody patch + // (enable_thinking) appear in the outgoing HTTP request body. Per M.E.AI ChatOptions.RawRepresentationFactory + // contract, the OpenAI bridge mutates the returned raw options with values from the strongly-typed + // ChatOptions properties before serialization. + var openAIClient = new global::OpenAI.OpenAIClient( + new global::System.ClientModel.ApiKeyCredential("NOKEY"), + new global::OpenAI.OpenAIClientOptions { Transport = new global::System.ClientModel.Primitives.HttpClientPipelineTransport(this._httpClient) }); + global::Microsoft.Extensions.AI.IChatClient chatClient = openAIClient.GetChatClient("gpt-4o").AsIChatClient(); + + var settings = new OpenAIPromptExecutionSettings + { + Temperature = 0.7, + MaxTokens = 256, + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + var chatOptions = settings.ToChatOptions(kernel: null); + + // Act + var messages = new[] { new global::Microsoft.Extensions.AI.ChatMessage(global::Microsoft.Extensions.AI.ChatRole.User, "hi") }; + await chatClient.GetResponseAsync(messages, chatOptions); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.Equal(0.7, body.GetProperty("temperature").GetDouble()); + Assert.Equal(256, body.GetProperty("max_completion_tokens").GetInt32()); + Assert.False(body.GetProperty("enable_thinking").GetBoolean()); + } + + private static JsonElement ParseRequestBody(byte[] requestContent) + { + var json = Encoding.UTF8.GetString(requestContent); + return JsonDocument.Parse(json).RootElement; + } + + private const string ChatCompletionResponse = """ + { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "Hello!" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + } + """; + + private const string ChatCompletionStreamingResponse = + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n"; +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs index 3387601ed189..7f8dfa41a2d9 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs @@ -548,6 +548,8 @@ protected virtual ChatCompletionOptions CreateChatCompletionOptions( } } + OpenAIPromptExecutionSettings.ApplyExtraBody(options, executionSettings); + return options; } diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs index 5824fe412f84..402e33f95175 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs @@ -447,6 +447,53 @@ public object? Audio } } + /// + /// Gets or sets a dictionary of additional fields to include in the request body sent to the OpenAI-compatible endpoint. + /// + /// + /// + /// This is an escape hatch for vendor-specific or preview parameters that are not modeled by + /// (for example, Qwen3's enable_thinking or ChatGLM thinking-mode flags). + /// + /// + /// Key syntax (hybrid): + /// + /// A plain key (without a leading $.) is treated as a literal top-level field name. + /// For example, ExtraBody["enable_thinking"] = false emits { "enable_thinking": false }. Keys containing + /// dots, brackets, or other special characters are bracket-quoted automatically and remain literal. + /// A key starting with $. is interpreted as a JSONPath expression and applied as a deep patch + /// onto the request body, allowing surgical edits of nested objects and arrays produced by the SDK + /// (for example, ExtraBody["$.stream_options.include_usage"] = true or ExtraBody["$.thinking.enabled"] = false). + /// + /// + /// + /// Collisions: if a key targets the same JSON property as a strongly-typed setting (for example, + /// ExtraBody["temperature"] = 0.0 with set to 0.7), the + /// value wins because it is applied during request serialization (last-write-wins). + /// + /// + /// Removal is not supported: setting a value to emits a JSON null; it does not remove + /// an SDK-injected field. Use a delegating HTTP handler if you need to remove fields from the outgoing body. + /// + /// + /// Round-trip behavior: when populated via YAML or JSON PromptTemplateConfig deserialization, values arrive + /// as instances. Read accordingly. + /// + /// + [Experimental("SKEXP0010")] + [JsonPropertyName("extra_body")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtraBody + { + get => this._extraBody; + + set + { + this.ThrowIfFrozen(); + this._extraBody = value; + } + } + /// public override void Freeze() { @@ -471,6 +518,11 @@ public override void Freeze() { this._metadata = new ReadOnlyDictionary(this._metadata); } + + if (this._extraBody is not null) + { + this._extraBody = new ReadOnlyDictionary(this._extraBody); + } } /// @@ -543,6 +595,7 @@ public static OpenAIPromptExecutionSettings FromExecutionSettings(PromptExecutio WebSearchOptions = this.WebSearchOptions, Modalities = this.Modalities, Audio = this.Audio, + ExtraBody = this.ExtraBody is not null ? new Dictionary(this.ExtraBody) : null, }; } @@ -563,6 +616,75 @@ protected override ChatHistory PrepareChatHistoryForRequest(ChatHistory chatHist return chatHistory; } + /// + protected override void PrepareChatOptionsForRequest(Microsoft.Extensions.AI.ChatOptions options) + { + base.PrepareChatOptionsForRequest(options); + + if (this._extraBody is null || this._extraBody.Count == 0) + { + return; + } + + var snapshot = new List>(this._extraBody); + + options.RawRepresentationFactory = (_) => + { + var raw = new ChatCompletionOptions(); + foreach (var kvp in snapshot) + { + ApplyExtraBodyEntry(raw, kvp.Key, kvp.Value); + } + return raw; + }; + + // The base ToChatOptions catch-all writes ExtraBody into AdditionalProperties via JSON round-trip. + // The factory above is the canonical carrier, so remove the duplicate here. + options.AdditionalProperties?.Remove("extra_body"); + } + + /// + /// Applies a single entry to the supplied using its + /// JsonPatch facility. Plain keys become bracket-quoted top-level fields; keys starting with $. are + /// passed through as raw JSONPath patches. + /// + internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string key, object? value) + { + string path = key.StartsWith("$.", StringComparison.Ordinal) + ? key + : $"$[{JsonSerializer.Serialize(key)}]"; + + byte[] pathBytes = System.Text.Encoding.UTF8.GetBytes(path); + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + if (value is null) + { + options.Patch.SetNull(pathBytes); + return; + } + + // Serialize to raw JSON bytes so any value type (primitive, JsonElement, dictionary, complex object) is supported uniformly. + byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value, value.GetType()); + options.Patch.Set(pathBytes, BinaryData.FromBytes(jsonBytes)); +#pragma warning restore SCME0001 + } + + /// + /// Applies all entries from . to . + /// + internal static void ApplyExtraBody(ChatCompletionOptions options, OpenAIPromptExecutionSettings settings) + { + if (settings._extraBody is null || settings._extraBody.Count == 0) + { + return; + } + + foreach (var kvp in settings._extraBody) + { + ApplyExtraBodyEntry(options, kvp.Key, kvp.Value); + } + } + #region private ================================================================================ private object? _webSearchOptions; @@ -586,6 +708,7 @@ protected override ChatHistory PrepareChatHistoryForRequest(ChatHistory chatHist private IDictionary? _metadata; private object? _responseModalities; private object? _audioOptions; + private IDictionary? _extraBody; #endregion } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs index 992651fda137..f83a11544b0f 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.TextGeneration; @@ -183,6 +184,21 @@ protected void ThrowIfFrozen() /// Prepared chat history. internal ChatHistory ChatClientPrepareChatHistoryForRequest(ChatHistory chatHistory) => this.PrepareChatHistoryForRequest(chatHistory); + /// + /// When some derived requires post-processing of the produced + /// (for example, setting ), + /// this method can be overridden. Called after the standard ToChatOptions conversion completes. + /// + /// The instance to prepare. + protected virtual void PrepareChatOptionsForRequest(ChatOptions options) { } + + /// + /// Internal bridge used by PromptExecutionSettingsExtensions.ToChatOptions without exposing + /// the protected publicly. + /// + /// Target to prepare. + internal void ChatClientPrepareChatOptionsForRequest(ChatOptions options) => this.PrepareChatOptionsForRequest(options); + #region private ================================================================================ private string? _modelId; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs index 59274f537261..d7976ad2dbd0 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs @@ -23,6 +23,10 @@ public static class PromptExecutionSettingsExtensions return null; } + // Preserve the original (potentially derived) settings instance so derived hooks can run after the + // base-type roundtrip below replaces 'settings' with a base PromptExecutionSettings. + PromptExecutionSettings derivedSettings = settings; + if (settings.GetType() != typeof(PromptExecutionSettings)) { var originalFunctionChoiceBehavior = settings.FunctionChoiceBehavior; @@ -175,13 +179,11 @@ public static class PromptExecutionSettingsExtensions options.ToolMode = ChatToolMode.Auto; options.AllowMultipleToolCalls = autoChoiceBehavior.Options?.AllowParallelCalls; } - else - if (settings.FunctionChoiceBehavior is NoneFunctionChoiceBehavior noneFunctionChoiceBehavior) + else if (settings.FunctionChoiceBehavior is NoneFunctionChoiceBehavior noneFunctionChoiceBehavior) { options.ToolMode = ChatToolMode.None; } - else - if (settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior requiredFunctionChoiceBehavior) + else if (settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior requiredFunctionChoiceBehavior) { options.ToolMode = ChatToolMode.RequireAny; options.AllowMultipleToolCalls = requiredFunctionChoiceBehavior.Options?.AllowParallelCalls; @@ -196,6 +198,11 @@ public static class PromptExecutionSettingsExtensions } } + // Allow derived PromptExecutionSettings to perform post-processing on the produced ChatOptions + // (for example, setting ChatOptions.RawRepresentationFactory). Invoke on the original derived instance + // since 'settings' may have been replaced with a base-type clone above. + derivedSettings.ChatClientPrepareChatOptionsForRequest(options); + // Enables usage of AutoFunctionInvocationFilters return kernel is null ? options From a1a701d051d425897518c3f70dc3d835cd949c7b Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:58:55 +0100 Subject: [PATCH 14/32] .Net: Fix whitespace formatting in PromptExecutionSettingsExtensions.cs (#13941) ### Motivation and Context The `PromptExecutionSettingsExtensions.cs` file had whitespace formatting errors (WHITESPACE and IDE0055 warnings) on lines 179-188 caused by `else` and `if` being split across separate lines instead of combined as `else if`. ### Description Combined split `else` / `if` statements into single `else if` lines in `PromptExecutionSettingsExtensions.cs`, resolving 10 whitespace formatting errors and IDE0055 warnings. ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 6e1ab9dee5e73131099a85314464287088bbd651 Mon Sep 17 00:00:00 2001 From: Schmitt Christian Date: Fri, 1 May 2026 10:29:20 +0200 Subject: [PATCH 15/32] .Net: Fix VertexAI global endpoint URI construction (#13620) (#13621) The global VertexAI location uses `https://aiplatform.googleapis.com` rather than the regional `https://{location}-aiplatform.googleapis.com` pattern. This adds a `GetVertexAIBaseUri` helper to ClientBase that selects the correct base URI and updates all VertexAI clients (chat completion, token counter, embeddings) to use it. Co-authored-by: Claude Opus 4.6 Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Clients/GeminiChatGenerationTests.cs | 47 +++++++++++++++++++ .../Clients/GeminiCountingTokensTests.cs | 45 ++++++++++++++++++ ...VertexAIClientEmbeddingsGenerationTests.cs | 47 +++++++++++++++++++ .../Connectors.Google/Core/ClientBase.cs | 10 ++++ .../Clients/GeminiChatCompletionClient.cs | 5 +- .../Clients/GeminiTokenCounterClient.cs | 3 +- .../Core/VertexAI/VertexAIEmbeddingClient.cs | 3 +- 7 files changed, 156 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs index 027195a6482a..aa5f40b1206d 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs @@ -565,6 +565,7 @@ public void ItThrowsOnLocationUrlInjectionAttempt(string maliciousLocation) [InlineData("us-central1-a")] [InlineData("northamerica-northeast1")] [InlineData("australia-southeast1")] + [InlineData("global")] public void ItAcceptsValidHostnameSegments(string validLocation) { // Arrange @@ -590,6 +591,52 @@ public void ItAcceptsValidHostnameSegments(string validLocation) Assert.Null(exception); } + [Fact] + public async Task ShouldUseGlobalEndpointWhenLocationIsGlobalAsync() + { + // Arrange + var client = new GeminiChatCompletionClient( + httpClient: this._httpClient, + modelId: "fake-model", + apiVersion: VertexAIVersion.V1, + bearerTokenProvider: () => new ValueTask("fake-key"), + location: "global", + projectId: "fake-project-id"); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/global/", requestUri, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldUseRegionalEndpointWhenLocationIsRegionalAsync() + { + // Arrange + var client = new GeminiChatCompletionClient( + httpClient: this._httpClient, + modelId: "fake-model", + apiVersion: VertexAIVersion.V1, + bearerTokenProvider: () => new ValueTask("fake-key"), + location: "us-central1", + projectId: "fake-project-id"); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://us-central1-aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/us-central1/", requestUri, StringComparison.Ordinal); + } + private sealed class BearerTokenGenerator() { private int _index = 0; diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs index dd28b46ddebe..2f6c443efd3e 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs @@ -184,6 +184,7 @@ public void ItThrowsOnLocationUrlInjectionAttempt(string maliciousLocation) [InlineData("us-central1-a")] [InlineData("northamerica-northeast1")] [InlineData("australia-southeast1")] + [InlineData("global")] public void ItAcceptsValidHostnameSegments(string validLocation) { // Arrange @@ -209,6 +210,50 @@ public void ItAcceptsValidHostnameSegments(string validLocation) Assert.Null(exception); } + [Fact] + public async Task ShouldUseGlobalEndpointWhenLocationIsGlobalAsync() + { + // Arrange + var client = new GeminiTokenCounterClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "global", + projectId: "fake-project-id"); + + // Act + await client.CountTokensAsync("fake-text"); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/global/", requestUri, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldUseRegionalEndpointWhenLocationIsRegionalAsync() + { + // Arrange + var client = new GeminiTokenCounterClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "us-central1", + projectId: "fake-project-id"); + + // Act + await client.CountTokensAsync("fake-text"); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://us-central1-aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/us-central1/", requestUri, StringComparison.Ordinal); + } + private sealed class BearerTokenGenerator() { private int _index = 0; diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs index 7b644512feda..7928428448ef 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs @@ -180,6 +180,7 @@ public void ItThrowsOnLocationUrlInjectionAttempt(string maliciousLocation) [InlineData("us-central1-a")] [InlineData("northamerica-northeast1")] [InlineData("australia-southeast1")] + [InlineData("global")] public void ItAcceptsValidHostnameSegments(string validLocation) { // Arrange @@ -205,6 +206,52 @@ public void ItAcceptsValidHostnameSegments(string validLocation) Assert.Null(exception); } + [Fact] + public async Task ShouldUseGlobalEndpointWhenLocationIsGlobalAsync() + { + // Arrange + var client = new VertexAIEmbeddingClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "global", + projectId: "fake-project-id"); + IList data = ["sample data"]; + + // Act + await client.GenerateEmbeddingsAsync(data); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/global/", requestUri, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldUseRegionalEndpointWhenLocationIsRegionalAsync() + { + // Arrange + var client = new VertexAIEmbeddingClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "us-central1", + projectId: "fake-project-id"); + IList data = ["sample data"]; + + // Act + await client.GenerateEmbeddingsAsync(data); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://us-central1-aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/us-central1/", requestUri, StringComparison.Ordinal); + } + private VertexAIEmbeddingClient CreateEmbeddingsClient( string modelId = "fake-model", string? bearerKey = "fake-key") diff --git a/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs b/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs index ed31204ea67e..10be44ef45a9 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs @@ -122,4 +122,14 @@ protected static string GetApiVersionSubLink(VertexAIVersion apiVersion) VertexAIVersion.V1_Beta => "v1beta1", _ => throw new NotSupportedException($"Vertex API version {apiVersion} is not supported.") }; + + /// + /// Gets the Vertex AI endpoint base URI for the given location. + /// The global location uses https://aiplatform.googleapis.com while + /// regional locations use https://{location}-aiplatform.googleapis.com. + /// + protected static string GetVertexAIBaseUri(string location) + => string.Equals(location, "global", StringComparison.OrdinalIgnoreCase) + ? "https://aiplatform.googleapis.com" + : $"https://{location}-aiplatform.googleapis.com"; } diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs index 3c3501622b74..e0138a8e9ce3 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs @@ -143,10 +143,11 @@ public GeminiChatCompletionClient( Verify.NotNullOrWhiteSpace(projectId); string versionSubLink = GetApiVersionSubLink(apiVersion); + string baseUri = GetVertexAIBaseUri(location); this._modelId = modelId; - this._chatGenerationEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:generateContent"); - this._chatStreamingEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:streamGenerateContent?alt=sse"); + this._chatGenerationEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:generateContent"); + this._chatStreamingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:streamGenerateContent?alt=sse"); } /// diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs index 057bd8bd86b0..2d50868e9ef7 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs @@ -74,9 +74,10 @@ public GeminiTokenCounterClient( Verify.NotNullOrWhiteSpace(projectId); string versionSubLink = GetApiVersionSubLink(apiVersion); + string baseUri = GetVertexAIBaseUri(location); this._modelId = modelId; - this._tokenCountingEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:countTokens"); + this._tokenCountingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:countTokens"); } /// diff --git a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs index e7b1c5343b61..cb59e0087481 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs @@ -51,9 +51,10 @@ public VertexAIEmbeddingClient( Verify.NotNullOrWhiteSpace(projectId); string versionSubLink = GetApiVersionSubLink(apiVersion); + string baseUri = GetVertexAIBaseUri(location); this._embeddingModelId = modelId; - this._embeddingEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._embeddingModelId}:predict"); + this._embeddingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._embeddingModelId}:predict"); this._dimensions = dimensions; } From 006a5d9a9b3eb4ed41f63beb6341bc44e724d9db Mon Sep 17 00:00:00 2001 From: Octopus Date: Fri, 1 May 2026 16:31:14 +0800 Subject: [PATCH 16/32] .Net: fix: fall back to ToString() when logging function results with unregistered types (#13884) Fixes #13681 ## Problem When a `KernelFunction` returns a value whose runtime type is not registered in `AbstractionsJsonContext` (e.g. `Microsoft.Extensions.AI.TextContent` returned by an MCP tool via `.AsKernelFunction()`), the function result logging in AOT/source-generation mode throws `NotSupportedException` during JSON serialization. The exception is caught but produces an unhelpful log entry: ``` Function SomePlugin-SomeTool result: Failed to log function result value System.NotSupportedException: JsonTypeInfo metadata for type 'Microsoft.Extensions.AI.TextContent' was not provided by TypeInfoResolver of type 'Microsoft.SemanticKernel.AbstractionsJsonContext'. ``` ## Solution Add a `ToString()` fallback in the `NotSupportedException` catch block of `LogFunctionResultValueInternal`. When JSON serialization fails because the runtime type is absent from the source-generated context, the logger now calls `resultValue?.Value?.ToString()` to produce useful output instead of the generic error message. ## Testing - Added `ItShouldFallBackToToStringWhenJsonSerializationIsNotSupported` unit test that uses a restricted source-generated `JsonSerializerContext` (containing only `object` and `IDictionary`) to simulate the AOT scenario, verifying that `ToString()` output appears in the log instead of the failure message. - All existing `ItShouldLogFunctionResultOfAnyType` test cases are unchanged. --------- Co-authored-by: octo-patch Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Functions/KernelFunctionLogMessages.cs | 17 +++++++- .../KernelFunctionLogMessagesTests.cs | 40 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs index 42c4b7f6e6a9..83374fe87002 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs @@ -161,7 +161,22 @@ private static void LogFunctionResultValueInternal(this ILogger logger, string? } catch (NotSupportedException ex) { - s_logFunctionResultValue(logger, pluginName, functionName, "Failed to log function result value", ex); + // Fall back to ToString() when JSON serialization isn't supported for this type + // (e.g. Microsoft.Extensions.AI.TextContent is not registered in AbstractionsJsonContext) + try + { + var toStringValue = resultValue?.Value?.ToString() ?? string.Empty; + s_logFunctionResultValue(logger, pluginName, functionName, toStringValue, null); + } + catch (Exception toStringEx) + { + s_logFunctionResultValue( + logger, + pluginName, + functionName, + "Failed to log function result value", + new AggregateException(ex, toStringEx)); + } } } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs index ec2642fa12e1..24daeccb6372 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; @@ -10,7 +11,7 @@ namespace SemanticKernel.UnitTests.Functions; -public class KernelFunctionLogMessagesTests +public partial class KernelFunctionLogMessagesTests { [Theory] [InlineData(typeof(string))] @@ -48,9 +49,46 @@ public void ItShouldLogFunctionResultOfAnyType(Type resultType) It.IsAny>())); } + [Fact] + public void ItShouldFallBackToToStringWhenJsonSerializationIsNotSupported() + { + // Arrange + var logger = new Mock(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + // TypeNotInJsonContext cannot be cast to string and is not registered in the restricted JSON context + var unserializableValue = new TypeNotInJsonContext(); + var functionResult = new FunctionResult(KernelFunctionFactory.CreateFromMethod(() => { }), unserializableValue); + + // Use a restricted JsonSerializerOptions that knows about object but not TypeNotInJsonContext, + // simulating the AOT scenario where AbstractionsJsonContext is used and an unregistered + // MEAI type (e.g. Microsoft.Extensions.AI.TextContent) is returned from an MCP tool. + var restrictedOptions = RestrictedJsonContext.Default.Options; + + // Act + logger.Object.LogFunctionResultValue("p1", "f1", functionResult, restrictedOptions); + + // Assert - ToString() fallback should have been used, not the error message + logger.Verify(l => l.Log( + LogLevel.Trace, + 0, + It.Is((o, _) => o.ToString() == "Function p1-f1 result: TypeNotInJsonContext()"), + null, + It.IsAny>())); + } + private sealed class User { [JsonPropertyName("name")] public string? Name { get; set; } } + + private sealed class TypeNotInJsonContext + { + public override string ToString() => "TypeNotInJsonContext()"; + } + + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(object))] + private sealed partial class RestrictedJsonContext : JsonSerializerContext { } } From 2a719ca3182f547203f54257f69c47a175fbb3fd Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Tue, 5 May 2026 16:33:11 +0100 Subject: [PATCH 17/32] .Net: Add deny-by-default AllowedUploadDirectories to CloudDrivePlugin (#13953) ### Description Add deny-by-default `AllowedUploadDirectories` to `CloudDrivePlugin`. - Added `AllowedUploadDirectories` property (defaults to empty = deny-all) - `UploadFileAsync` now validates the local file path against the allowlist before uploading - Paths are canonicalized with environment variable expansion, UNC rejection, and directory traversal protection - Added unit tests for the new behavior --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Plugins.MsGraph/CloudDrivePlugin.cs | 95 +++++++++- .../MsGraph/CloudDrivePluginTests.cs | 166 +++++++++++++++++- 2 files changed, 257 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs b/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs index de8660092fe4..374bdd4ef1de 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs +++ b/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -14,10 +16,21 @@ namespace Microsoft.SemanticKernel.Plugins.MsGraph; /// /// Cloud drive plugin (e.g. OneDrive). /// +/// +/// +/// This plugin is secure by default. must be explicitly configured +/// before any file upload operations are permitted. By default, all local file paths are denied. +/// +/// +/// When exposing this plugin to an LLM via auto function calling, ensure that +/// is restricted to trusted values only. +/// +/// public sealed class CloudDrivePlugin { private readonly ICloudDriveConnector _connector; private readonly ILogger _logger; + private HashSet _allowedUploadDirectories = []; /// /// Initializes a new instance of the class. @@ -32,6 +45,20 @@ public CloudDrivePlugin(ICloudDriveConnector connector, ILoggerFactory? loggerFa this._logger = loggerFactory?.CreateLogger(typeof(CloudDrivePlugin)) ?? NullLogger.Instance; } + /// + /// List of allowed local directories from which files may be uploaded. Subdirectories of allowed directories are also permitted. + /// + /// + /// Defaults to an empty collection (no directories allowed). Must be explicitly populated + /// with trusted directory paths before any file upload operations will succeed. + /// Paths are canonicalized before validation to prevent directory traversal. + /// + public IEnumerable AllowedUploadDirectories + { + get => this._allowedUploadDirectories; + set => this._allowedUploadDirectories = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + /// /// Get the contents of a file stored in a cloud drive. /// @@ -77,10 +104,19 @@ public async Task UploadFileAsync( throw new ArgumentException("Variable was null or whitespace", nameof(destinationPath)); } - this._logger.LogDebug("Uploading file '{0}'", filePath); + Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); + + var canonicalPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(filePath)); + + if (!this.IsUploadPathAllowed(canonicalPath)) + { + throw new InvalidOperationException("Uploading from the provided location is not allowed. Configure 'AllowedUploadDirectories' with trusted directory paths to enable uploads."); + } + + this._logger.LogDebug("Uploading file '{0}'", canonicalPath); // TODO Add support for large file uploads (i.e. upload sessions) - await this._connector.UploadSmallFileAsync(filePath, destinationPath, cancellationToken).ConfigureAwait(false); + await this._connector.UploadSmallFileAsync(canonicalPath, destinationPath, cancellationToken).ConfigureAwait(false); } /// @@ -100,4 +136,59 @@ public async Task CreateLinkAsync( return await this._connector.CreateShareLinkAsync(filePath, Type, Scope, cancellationToken).ConfigureAwait(false); } + + #region private + // Use case-insensitive comparison on Windows (case-insensitive FS), case-sensitive on Linux/macOS. + private static readonly StringComparison s_pathComparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// If a list of allowed upload directories has been provided, the directory of the provided filePath is checked + /// to verify it is in the allowed directory list. Paths are canonicalized before comparison. + /// Subdirectories of allowed directories are also permitted. + /// + private bool IsUploadPathAllowed(string path) + { + Ensure.NotNullOrWhitespace(path, nameof(path)); + + if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); + } + + string? directoryPath = Path.GetDirectoryName(path); + + if (string.IsNullOrEmpty(directoryPath)) + { + throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(path)); + } + + if (this._allowedUploadDirectories.Count == 0) + { + return false; + } + + var canonicalDir = Path.GetFullPath(directoryPath); + + foreach (var allowedDirectory in this._allowedUploadDirectories) + { + var canonicalAllowed = Path.GetFullPath(allowedDirectory); + var separator = Path.DirectorySeparatorChar.ToString(); + if (!canonicalAllowed.EndsWith(separator, s_pathComparison)) + { + canonicalAllowed += separator; + } + + if (canonicalDir.StartsWith(canonicalAllowed, s_pathComparison) + || (canonicalDir + separator).Equals(canonicalAllowed, s_pathComparison)) + { + return true; + } + } + + return false; + } + #endregion } diff --git a/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs index ee15a1a92725..a6e34f14dbe2 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -17,13 +18,14 @@ public class CloudDrivePluginTests public async Task UploadSmallFileAsyncSucceedsAsync() { // Arrange - string anyFilePath = Guid.NewGuid().ToString(); + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); Mock connectorMock = new(); connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - CloudDrivePlugin target = new(connectorMock.Object); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; // Act await target.UploadFileAsync(anyFilePath, Guid.NewGuid().ToString()); @@ -74,4 +76,164 @@ public async Task GetFileContentAsyncSucceedsAsync() Assert.Equal(expectedContent, actual); connectorMock.VerifyAll(); } + + [Fact] + public async Task ItDeniesAllPathsByDefaultAsync() + { + // Arrange + string filePath = Path.Combine(Path.GetTempPath(), "somefile.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object); + + // Act & Assert — default config denies all paths + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(filePath, "/remote.txt")); + } + + [Fact] + public async Task ItDeniesPathTraversalAsync() + { + // Arrange + var allowedDir = Path.Combine(Path.GetTempPath(), "allowed-folder"); + var traversalPath = Path.Combine(allowedDir, "..", "outside-folder", "secret.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + + // Act & Assert — traversal path is canonicalized and rejected + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(traversalPath, "/remote.txt")); + } + + [Fact] + public async Task ItDeniesUncPathsAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()] }; + + // Act & Assert — UNC paths are rejected (ArgumentException on Windows, InvalidOperationException on Linux + // where the path is canonicalized differently and fails the allowlist check instead) + await Assert.ThrowsAnyAsync(async () => + await target.UploadFileAsync("\\\\UNC\\server\\folder\\file.txt", "/remote.txt")); + } + + [Fact] + public async Task ItDeniesDisallowedDirectoriesAsync() + { + // Arrange + var allowedDir = Path.Combine(Path.GetTempPath(), "allowed"); + var disallowedPath = Path.Combine(Path.GetTempPath(), "disallowed", "file.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(disallowedPath, "/remote.txt")); + } + + [Fact] + public async Task ItAllowsSubdirectoriesOfAllowedDirectoriesAsync() + { + // Arrange + var allowedDir = Path.GetTempPath(); + var subDirPath = Path.Combine(allowedDir, "subdir", "nested", "file.txt"); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + + // Act — subdirectory of allowed folder should succeed + await target.UploadFileAsync(subDirPath, "/remote.txt"); + + // Assert + connectorMock.VerifyAll(); + } + + [Fact] + public async Task ItExpandsEnvironmentVariablesAndValidatesAsync() + { + // Arrange — set a dedicated test env var to avoid platform-specific assumptions + var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var envVarName = "SK_TEST_UPLOAD_DIR"; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + try + { + Environment.SetEnvironmentVariable(envVarName, tempDir); + var envVarPath = Path.Combine($"%{envVarName}%", "testfile.txt"); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [tempDir] }; + + // Act — env var should be expanded and path should be allowed + await target.UploadFileAsync(envVarPath, "/remote.txt"); + + // Assert + connectorMock.VerifyAll(); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task ItDeniesExpandedEnvironmentVariablePathsOutsideAllowedAsync() + { + // Arrange — set a dedicated test env var; allow a subdirectory but env var expands outside it + var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var allowedDir = Path.Combine(tempDir, "specific-allowed"); + var envVarName = "SK_TEST_UPLOAD_DIR"; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + try + { + Environment.SetEnvironmentVariable(envVarName, tempDir); + var envVarPath = Path.Combine($"%{envVarName}%", "outside-file.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + + // Act & Assert — expanded path is outside allowed directory + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(envVarPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task ItRespectsPlatformCaseSensitivityAsync() + { + // Arrange — use differently-cased allowed dir vs file path + var allowedDir = Path.Combine(Path.GetTempPath(), "AllowedFolder"); + var filePath = Path.Combine(Path.GetTempPath(), "allowedfolder", "file.txt"); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows: case-insensitive FS — differently-cased path should be allowed + await target.UploadFileAsync(filePath, "/remote.txt"); + connectorMock.VerifyAll(); + } + else + { + // Linux/macOS: case-sensitive FS — differently-cased path should be denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(filePath, "/remote.txt")); + } + } } From 1a5065e5cf25536ffe94ff1fba7713f300c7c9b7 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 6 May 2026 16:10:48 +0100 Subject: [PATCH 18/32] .Net: Fix DocumentPlugin path validation order (#13956) ### Motivation and Context Fix path validation order in DocumentPlugin to canonicalize paths before validating against AllowedDirectories. Also removes redundant path transformation from LocalFileSystemConnector. ### Description - Canonicalize file paths (expand environment variables and resolve to absolute form) **before** validating against AllowedDirectories in both `ReadTextAsync` and `AppendTextAsync` - Remove environment variable expansion from `LocalFileSystemConnector` the connector should operate on the path it receives, not transform it - Add regression tests ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] The existing tests pass - [x] New tests added --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Plugins.Document/DocumentPlugin.cs | 70 +++++++--- .../FileSystem/LocalFileSystemConnector.cs | 8 +- .../Document/DocumentPluginTests.cs | 130 +++++++++++++++++- 3 files changed, 179 insertions(+), 29 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs b/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs index f0f3a1fd37c8..2cc87da09a48 100644 --- a/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs +++ b/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -87,14 +88,16 @@ public async Task ReadTextAsync( [Description("Path to the file to read")] string filePath, CancellationToken cancellationToken = default) { - this._logger.LogDebug("Reading text from {0}", filePath); + var canonicalPath = CanonicalizePath(filePath); - if (!this.IsFilePathAllowed(filePath)) + this._logger.LogDebug("Reading text from {0}", canonicalPath); + + if (!this.IsFilePathAllowed(canonicalPath)) { throw new InvalidOperationException("Reading from the provided location is not allowed."); } - using var stream = await this._fileSystemConnector.GetFileContentStreamAsync(filePath, cancellationToken).ConfigureAwait(false); + using var stream = await this._fileSystemConnector.GetFileContentStreamAsync(canonicalPath, cancellationToken).ConfigureAwait(false); return this._documentConnector.ReadText(stream); } @@ -107,25 +110,27 @@ public async Task AppendTextAsync( [Description("Destination file path")] string filePath, CancellationToken cancellationToken = default) { - if (!this.IsFilePathAllowed(filePath)) + var canonicalPath = CanonicalizePath(filePath); + + if (!this.IsFilePathAllowed(canonicalPath)) { throw new InvalidOperationException("Writing to the provided location is not allowed."); } // If the document already exists, open it. If not, create it. - if (await this._fileSystemConnector.FileExistsAsync(filePath, cancellationToken).ConfigureAwait(false)) + if (await this._fileSystemConnector.FileExistsAsync(canonicalPath, cancellationToken).ConfigureAwait(false)) { - this._logger.LogDebug("Writing text to file {0}", filePath); - using Stream stream = await this._fileSystemConnector.GetWriteableFileStreamAsync(filePath, cancellationToken).ConfigureAwait(false); + this._logger.LogDebug("Writing text to file {0}", canonicalPath); + using Stream stream = await this._fileSystemConnector.GetWriteableFileStreamAsync(canonicalPath, cancellationToken).ConfigureAwait(false); this._documentConnector.AppendText(stream, text); } else { - this._logger.LogDebug("File does not exist. Creating file at {0}", filePath); - using Stream stream = await this._fileSystemConnector.CreateFileAsync(filePath, cancellationToken).ConfigureAwait(false); + this._logger.LogDebug("File does not exist. Creating file at {0}", canonicalPath); + using Stream stream = await this._fileSystemConnector.CreateFileAsync(canonicalPath, cancellationToken).ConfigureAwait(false); this._documentConnector.Initialize(stream); - this._logger.LogDebug("Writing text to {0}", filePath); + this._logger.LogDebug("Writing text to {0}", canonicalPath); this._documentConnector.AppendText(stream, text); } } @@ -134,11 +139,10 @@ public async Task AppendTextAsync( private HashSet? _allowedDirectories = []; /// - /// If a list of allowed directories has been provided, the directory of the provided filePath is checked - /// to verify it is in the allowed directory list. Paths are canonicalized before comparison. - /// Subdirectories of allowed directories are also permitted. + /// Expands environment variables and resolves the path to its canonical form. + /// This must be called before validation to prevent validate/use mismatches. /// - private bool IsFilePathAllowed(string path) + private static string CanonicalizePath(string path) { Verify.NotNullOrWhiteSpace(path); @@ -147,11 +151,37 @@ private bool IsFilePathAllowed(string path) throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); } - string? directoryPath = Path.GetDirectoryName(path); + // Expand environment variables first, then canonicalize — so that + // validation and I/O operate on the same resolved path. + var expanded = Environment.ExpandEnvironmentVariables(path); + + // Re-check after expansion: an env var could have expanded to a UNC + // or extended-path prefix (e.g., %NETSHARE% → \\server\share). + if (expanded.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); + } + + return Path.GetFullPath(expanded); + } + + // Use case-insensitive comparison on Windows (case-insensitive FS), case-sensitive on Linux/macOS. + private static readonly StringComparison s_pathComparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// Checks whether a canonicalized file path falls within one of the allowed directories. + /// Subdirectories of allowed directories are also permitted. + /// + private bool IsFilePathAllowed(string canonicalPath) + { + string? directoryPath = Path.GetDirectoryName(canonicalPath); if (string.IsNullOrEmpty(directoryPath)) { - throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(path)); + throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(canonicalPath)); } if (this._allowedDirectories is null || this._allowedDirectories.Count == 0) @@ -159,19 +189,17 @@ private bool IsFilePathAllowed(string path) return false; } - var canonicalDir = Path.GetFullPath(directoryPath); - foreach (var allowedDirectory in this._allowedDirectories) { var canonicalAllowed = Path.GetFullPath(allowedDirectory); var separator = Path.DirectorySeparatorChar.ToString(); - if (!canonicalAllowed.EndsWith(separator, StringComparison.OrdinalIgnoreCase)) + if (!canonicalAllowed.EndsWith(separator, s_pathComparison)) { canonicalAllowed += separator; } - if (canonicalDir.StartsWith(canonicalAllowed, StringComparison.OrdinalIgnoreCase) - || (canonicalDir + separator).Equals(canonicalAllowed, StringComparison.OrdinalIgnoreCase)) + if (directoryPath.StartsWith(canonicalAllowed, s_pathComparison) + || (directoryPath + separator).Equals(canonicalAllowed, s_pathComparison)) { return true; } diff --git a/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs b/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs index fd708eb24af1..2fef0ae8db2d 100644 --- a/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs +++ b/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs @@ -32,7 +32,7 @@ public Task GetFileContentStreamAsync(string filePath, CancellationToken { try { - return Task.FromResult(File.Open(Environment.ExpandEnvironmentVariables(filePath), FileMode.Open, FileAccess.Read)); + return Task.FromResult(File.Open(filePath, FileMode.Open, FileAccess.Read)); } catch (Exception e) { @@ -58,7 +58,7 @@ public Task GetWriteableFileStreamAsync(string filePath, CancellationTok { try { - return Task.FromResult(File.Open(Environment.ExpandEnvironmentVariables(filePath), FileMode.Open, FileAccess.ReadWrite)); + return Task.FromResult(File.Open(filePath, FileMode.Open, FileAccess.ReadWrite)); } catch (Exception e) { @@ -82,7 +82,7 @@ public Task CreateFileAsync(string filePath, CancellationToken cancellat { try { - return Task.FromResult(File.Create(Environment.ExpandEnvironmentVariables(filePath))); + return Task.FromResult(File.Create(filePath)); } catch (Exception e) { @@ -95,7 +95,7 @@ public Task FileExistsAsync(string filePath, CancellationToken cancellatio { try { - return Task.FromResult(File.Exists(Environment.ExpandEnvironmentVariables(filePath))); + return Task.FromResult(File.Exists(filePath)); } catch (Exception e) { diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs index d68b9833847a..1308485da8e8 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs @@ -239,15 +239,137 @@ public async Task ItAllowsSubdirectoriesOfAllowedFoldersAsync() [Fact] public async Task ItDeniesRelativePathsAsync() { - // Arrange + // Arrange — use a unique subfolder so this test is deterministic regardless of CWD + var allowedFolder = Path.Combine(Path.GetTempPath(), "unique-allowed-" + Guid.NewGuid().ToString("N")[..8]); var fileSystemConnectorMock = new Mock(); var documentConnectorMock = new Mock(); var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) { - AllowedDirectories = [Path.GetTempPath()] + AllowedDirectories = [allowedFolder] }; - // Act & Assert — relative paths are caught by the "fully qualified" check - await Assert.ThrowsAsync(async () => await target.ReadTextAsync("myfile.docx")); + // Act & Assert — relative paths resolve to CWD after canonicalization, + // which will be outside the allowed directories + await Assert.ThrowsAsync(async () => await target.ReadTextAsync("myfile.docx")); + await Assert.ThrowsAsync(async () => await target.AppendTextAsync("text", "myfile.docx")); + } + + [Fact] + public async Task ItDeniesEnvVarExpansionBypassOnReadAsync() + { + // Arrange — use a test-specific env var that expands to a value containing + // a path separator + ".." which creates a traversal after expansion. + var allowedFolder = Path.Combine(Path.GetTempPath(), "allowed-sandbox"); + var envVarName = "SK_TEST_EXPAND_" + Guid.NewGuid().ToString("N")[..8]; + + try + { + // The env var value starts with a separator + ".." so that after expansion + // the path becomes: allowed-sandbox/..elsewheresecret.docx + Environment.SetEnvironmentVariable(envVarName, + $"{Path.DirectorySeparatorChar}..{Path.DirectorySeparatorChar}elsewhere"); + var maliciousPath = Path.Combine(allowedFolder, $"%{envVarName}%", "secret.docx"); + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert — the path should be denied because env vars are expanded + // before validation, so the canonical path lands outside the allowed directory. + await Assert.ThrowsAsync(async () => await target.ReadTextAsync(maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } + } + + [Fact] + public async Task ItDeniesEnvVarExpansionBypassOnWriteAsync() + { + // Arrange — same pattern as read test, for the write path. + var allowedFolder = Path.Combine(Path.GetTempPath(), "allowed-sandbox"); + var envVarName = "SK_TEST_EXPAND_W_" + Guid.NewGuid().ToString("N")[..8]; + + try + { + Environment.SetEnvironmentVariable(envVarName, + $"{Path.DirectorySeparatorChar}..{Path.DirectorySeparatorChar}elsewhere"); + var maliciousPath = Path.Combine(allowedFolder, $"%{envVarName}%", "secret.docx"); + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert + await Assert.ThrowsAsync(async () => await target.AppendTextAsync("text", maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } + } + + [Fact] + public async Task ItDeniesEnvVarExpansionToAbsolutePathAsync() + { + // Arrange — env var that expands to an absolute path outside the sandbox + var allowedFolder = Path.Combine(Path.GetTempPath(), "sandbox"); + var envVarName = "SK_TEST_ABS_" + Guid.NewGuid().ToString("N")[..8]; + var outsidePath = Path.Combine(Path.GetTempPath(), "outside"); + + try + { + Environment.SetEnvironmentVariable(envVarName, outsidePath); + var maliciousPath = Path.Combine($"%{envVarName}%", "secret.docx"); + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert — after env-var expansion, the path resolves outside the sandbox + await Assert.ThrowsAsync(async () => await target.ReadTextAsync(maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } + } + + [Fact] + public async Task ItDeniesUncPathsIntroducedViaEnvVarExpansionAsync() + { + // Arrange — env var that expands to a UNC path + var allowedFolder = Path.Combine(Path.GetTempPath(), "sandbox"); + var envVarName = "SK_TEST_UNC_" + Guid.NewGuid().ToString("N")[..8]; + + try + { + Environment.SetEnvironmentVariable(envVarName, @"\\evil-server\share"); + var maliciousPath = $"%{envVarName}%{Path.DirectorySeparatorChar}secret.docx"; + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert — expanded path is UNC, should be rejected + await Assert.ThrowsAsync(async () => await target.ReadTextAsync(maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } } } From fb10d92d8c9c21d1a0122f53781bd1bee5acd25f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 13:56:07 +0100 Subject: [PATCH 19/32] .Net: Bump Snappier to 1.3.1 to fix NU1903 high-severity vulnerability (GHSA-pggp-6c3x-2xmx) (#13960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation and Context `MongoDB.Driver 3.5.2` transitively introduces `Snappier 1.0.0`, which carries a high-severity vulnerability (GHSA-pggp-6c3x-2xmx): infinite loop during SnappyStream decompression of malformed framed input. All Snappier versions ≤ 1.3.0 are affected; 1.3.1 is the first patched release. This was blocking the merge queue via NU1903. ### Description - **`dotnet/Directory.Packages.props`** — Add `PackageVersion` entry pinning `Snappier` to `1.3.1`. - **`dotnet/src/VectorData/MongoDB/MongoDB.csproj`** — Add explicit `PackageReference` for `Snappier` (versionless, resolved via CPM) so NuGet treats it as a direct dependency at 1.3.1, overriding the transitive 1.0.0 from `MongoDB.Driver`. - **`dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj`** — Same override for the CosmosMongoDB connector. In NuGet's resolution algorithm, a direct reference at depth 1 wins over a transitive reference at depth 2, so this cleanly forces 1.3.1 without changing the `MongoDB.Driver` pin itself. ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile:
Original prompt Please investigate a solution to snappier vulnerability fix (update version) and propose a PR with the bump, ideally following the first immediate a non breaking version bump **Chronological Review:** 1. User asked to analyze PR #13431 (Gemini multimodal tool results in microsoft/semantic-kernel) for gaps 2. Initial analysis identified ~11 gaps with broad scope including OpenAI Assistants, AzureAI, Responses API, MistralAI 3. User invoked ouroboros skill, asked to reassess scoping to Google package only 4. Reassessment: Google connector bypasses FunctionCallsProcessor; only validated regression is OpenAI Responses API 5. User invoked ouroboros to seed the observation; created seed file `pr-13431-followup.seed.yaml` 6. User asked to execute seed + identify pipeline error 7. Implemented 5 file changes; identified pipeline error CS8602 in GeminiRequestTests.cs:809 8. All builds/tests passed; user asked about string→object impact in plan mode 9. Investigation revealed `FunctionCallsProcessor` is `internal sealed`, source-distributed, so blast radius small 10. User confirmed all implemented; ran CI-parity dotnet format via WSL2+Docker (all pass) 11. User asked to commit and push - committed `de08bce99` and pushed to Cozmopolit fork 12. User asked to check PR comments - found 2 Copilot bot review comments 13. User said: add OpenAI test (item 1), reply out-of-scope (item 2) 14. Added OpenAI ChatCompletion test, committed `e9f27d21a`, pushed, replied to both bot comments 15. User asked PR number (13431) 16. User invoked /auto pr_task - PR was green, no action needed 17. User said merge queue failed, asked to investigate 18. Investigation found: NU1903 Snappier 1.0.0 vulnerability blocking merge queue; not caused by our PR 19. **Most recent: User asked "Do we have a fix in main for this already?"** 20. **Investigation confirmed: NO fix in main** - origin/main HEAD `1a5065e5c` unchanged, `MongoDB.Driver 3.5.2` still pinned, no Snappier override, no PRs/issues for Snappier or NU1903 in the repo recently 21. **Offered to open a small fix PR pinning Snappier to a patched version** **Intent Mapping:** - User wants PR #13431 fully landed - User wants to understand whether merge queue failure is in scope - User now considering whether to fix the Snappier vulnerability separately **Technical Inventory:** - .NET 10.0, semantic-kernel repo - WSL2 + Docker (mcr.microsoft.com/dotnet/sdk:10.0) for CI parity - gh CLI for PR/CI operations - Git remotes: origin=microsoft, roger=rogerbarreto fork, cozmopolit=Cozmopolit fork (added) - Branch: `fix/multimodal-tool-results` - PR head fork: Cozmopolit, maintainerCanModify=true **Code Archaeology:** Files changed in commits `de08bce99` + `e9f27d21a`: - `dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs` — added GetFunctionResultAsString helper - `dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs` — new (3 tests) - `dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs` — extracted s_imageFunctionResponseEnvelope - `dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs` — CS8602 fix - `dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs` — XML-doc tightening - `dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs` — added ItSendsImageContentNotSupportedErrorWhenToolResultIsImageContentAsync Identified vulnerable package: `MongoDB.Driver 3.5.2` in `dotnet/Directory.Packages.props:173` brings in transitive `Snappier 1.0.0` **Progress Assessment:** - ✅ All planned implementation completed - ✅ Both bot review comments replied to - ✅ All 7 todos done - ⚠️ PR cannot merge due to environmental Snappier vulnerability (not our PR's fault) - 🔲 User considering whether to open a separate Snappier fix PR **Context Validation:** - PR state: OPEN, MERGEABLE, CLEAN, APPROVED (markwallace-microsoft) - HEAD SHA: `e9f27d21a85a8b97ee5f29619b23b82a03e313ff` - All required CI checks pass on PR head - Merge queue attempt failed at 2026-05-07T10:01:31, removed by github-merge-queue[bot] at 10:08:05 - Failed merge_group run: 25489152862 **Recent Commands Analysis:** Most recent two tool batches: 1. `git fetch origin main; git log origin/main --since="2026-05-06T15:00:00Z" --oneline` → only `1a5065e5c` (yesterday's commit) 2. `git show origin/main:dotnet/Directory.Packages.props | grep Snappier|MongoDB.Driver` → only `MongoDB.Driver 3.5.2`, no Snappier override 3. `gh search prs/issues --state all "Snappier"` → command failed (invalid `all` state) 4. `gh api search/issues?q=Snappier+repo:microsoft/semantic-kernel` → empty results 5. `gh api search/issues?q=NU1903+repo:microsoft/semantic-kernel` → 3 historical results from 2024, all unrelated Final response: confirmed NO fix in main, offered to open a small fix PR pinning Snappier. 1. Conversation Overview: - Primary Objectives: - Analyze SK PR #13431 (G...
Created from Copilot CLI via the copilot delegate command. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- dotnet/Directory.Packages.props | 1 + dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj | 1 + dotnet/src/VectorData/MongoDB/MongoDB.csproj | 1 + 3 files changed, 3 insertions(+) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 9cae6f0a8648..2eaf16a66618 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -171,6 +171,7 @@ + diff --git a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj index 52b98d55b40e..6cfc45fc2881 100644 --- a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj +++ b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj @@ -30,6 +30,7 @@ + diff --git a/dotnet/src/VectorData/MongoDB/MongoDB.csproj b/dotnet/src/VectorData/MongoDB/MongoDB.csproj index fa6369f2e179..d3200c604133 100644 --- a/dotnet/src/VectorData/MongoDB/MongoDB.csproj +++ b/dotnet/src/VectorData/MongoDB/MongoDB.csproj @@ -30,6 +30,7 @@ + From 73d3c59902914f79d3068a54aaa851d275c91de8 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 7 May 2026 18:32:44 +0100 Subject: [PATCH 20/32] Update Kiota packages to fix NU1903 vulnerability (#13966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem NuGet audit reports NU1903 for `Microsoft.Kiota.Abstractions` < 1.22.0 ([GHSA-7j59-v9qr-6fq9](https://github.com/advisories/GHSA-7j59-v9qr-6fq9)) — the Kiota `RedirectHandler` leaks `Cookie` and `Proxy-Authorization` headers on cross-host redirects. The vulnerability is pulled in transitively via `Microsoft.Graph` → `Microsoft.Graph.Core` 3.2.5 → `Microsoft.Kiota.Abstractions` 1.17.1. ## Fix - **`Microsoft.Graph`**: 5.94.0 → 5.105.0 - **`Microsoft.Graph.Core`**: added at 3.2.6 (requires `Kiota.Abstractions` ≥ 1.22.1) - **`Microsoft.Kiota.Abstractions`**: added at 1.22.2 - **`Microsoft.IdentityModel.JsonWebTokens`**: 8.15.0 → 8.16.0 (required by Graph.Core 3.2.6) - **`System.IdentityModel.Tokens.Jwt`**: 8.15.0 → 8.16.0 (required by Graph.Core 3.2.6) - Added direct `Microsoft.Graph.Core` `PackageReference` in `Plugins.MsGraph.csproj` to pin the transitive version ## Verification All three affected projects report no vulnerable packages after the change: - `Plugins.MsGraph` - `Concepts` - `Plugins.UnitTests` --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 14 ++++++++------ .../BookingRestaurant/BookingRestaurant.csproj | 6 +++--- .../Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj | 1 + 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 2eaf16a66618..1947e6bede0c 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -73,10 +73,11 @@ - - - - + + + + + @@ -108,7 +109,7 @@ - + @@ -172,7 +173,8 @@ - + + diff --git a/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj b/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj index 0b8e2c9c41a2..7fac79459d9a 100644 --- a/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj +++ b/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj @@ -20,9 +20,9 @@ - - - + + + diff --git a/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj b/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj index cda3a63176f2..9310761a978c 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj +++ b/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj @@ -19,6 +19,7 @@ + From 52d4e5ce857bcdc770eb9ea415e2092aae3fa258 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 7 May 2026 19:00:17 +0100 Subject: [PATCH 21/32] .Net: Harden gRPC plugin address handling (#13961) ## Description Add developer-controlled address configuration and validation for the gRPC plugin. ### Motivation and Context The gRPC plugin now supports `GrpcFunctionExecutionParameters` for configuring address handling, including an optional address override, allowed base addresses, and scheme restrictions. ### Changes - Introduced `GrpcFunctionExecutionParameters` class for developer-controlled gRPC channel configuration - Added address validation with scheme and allowlist checks in `GrpcOperationRunner` - Plumbed execution parameters through all public methods in `GrpcKernelExtensions` - Updated and added unit tests ### Type of change - [x] Bug fix (non-breaking change which fixes an issue) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GrpcFunctionExecutionParameters.cs | 41 +++ .../Extensions/GrpcKernelExtensions.cs | 42 ++- .../Functions.Grpc/GrpcOperationRunner.cs | 96 ++++++- .../Functions.Grpc/Model/GrpcOperation.cs | 11 - .../GrpcOperationExtensionsTests.cs | 5 +- .../Grpc/GrpcRunnerTests.cs | 271 +++++++++++++++++- 6 files changed, 429 insertions(+), 37 deletions(-) create mode 100644 dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs diff --git a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs new file mode 100644 index 000000000000..16a23f6100bb --- /dev/null +++ b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; + +namespace Microsoft.SemanticKernel.Plugins.Grpc; + +/// +/// gRPC function execution parameters. +/// +[Experimental("SKEXP0040")] +public class GrpcFunctionExecutionParameters +{ + /// + /// HttpClient to use for sending gRPC requests. + /// + public HttpClient? HttpClient { get; set; } + + /// + /// Developer-provided address override for the gRPC channel. + /// When set, this address is used instead of the one from the .proto document. + /// This value is controlled by the developer, not by the LLM. + /// + public Uri? AddressOverride { get; set; } + + /// + /// Gets or sets the allowed gRPC server base addresses. + /// If set, only requests to addresses that start with one of these base URIs will be permitted. + /// This helps prevent Server-Side Request Forgery (SSRF) attacks. + /// If null, no base address restriction is applied (scheme validation still applies). + /// + public IReadOnlyList? AllowedAddresses { get; set; } + + /// + /// Gets or sets the allowed URI schemes for gRPC server addresses. + /// If null or empty, only https is permitted. + /// + public IReadOnlyList? AllowedSchemes { get; set; } +} diff --git a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs index 2b2b7488db6a..6d7331b05f90 100644 --- a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs +++ b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs @@ -29,13 +29,15 @@ public static class GrpcKernelExtensions /// The containing services, plugins, and other state for use throughout the operation. /// Directory containing the plugin directory. /// Name of the directory containing the selected plugin. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin ImportPluginFromGrpcDirectory( this Kernel kernel, string parentDirectory, - string pluginDirectoryName) + string pluginDirectoryName, + GrpcFunctionExecutionParameters? executionParameters = null) { - KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName); + KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName, executionParameters); kernel.Plugins.Add(plugin); return plugin; } @@ -46,13 +48,15 @@ public static KernelPlugin ImportPluginFromGrpcDirectory( /// The containing services, plugins, and other state for use throughout the operation. /// File path to .proto document. /// Name of the plugin to register. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin ImportPluginFromGrpcFile( this Kernel kernel, string filePath, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { - KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName); + KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName, executionParameters); kernel.Plugins.Add(plugin); return plugin; } @@ -63,13 +67,15 @@ public static KernelPlugin ImportPluginFromGrpcFile( /// The containing services, plugins, and other state for use throughout the operation. /// .proto document stream. /// Plugin name. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin ImportPluginFromGrpc( this Kernel kernel, Stream documentStream, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { - KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName); + KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName, executionParameters); kernel.Plugins.Add(plugin); return plugin; } @@ -80,11 +86,13 @@ public static KernelPlugin ImportPluginFromGrpc( /// The containing services, plugins, and other state for use throughout the operation. /// Directory containing the plugin directory. /// Name of the directory containing the selected plugin. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin CreatePluginFromGrpcDirectory( this Kernel kernel, string parentDirectory, - string pluginDirectoryName) + string pluginDirectoryName, + GrpcFunctionExecutionParameters? executionParameters = null) { const string ProtoFile = "grpc.proto"; @@ -107,7 +115,7 @@ public static KernelPlugin CreatePluginFromGrpcDirectory( using var stream = File.OpenRead(filePath); - return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName); + return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName, executionParameters); } /// @@ -116,11 +124,13 @@ public static KernelPlugin CreatePluginFromGrpcDirectory( /// The containing services, plugins, and other state for use throughout the operation. /// File path to .proto document. /// Name of the plugin to register. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin CreatePluginFromGrpcFile( this Kernel kernel, string filePath, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { if (!File.Exists(filePath)) { @@ -135,7 +145,7 @@ public static KernelPlugin CreatePluginFromGrpcFile( using var stream = File.OpenRead(filePath); - return kernel.CreatePluginFromGrpc(stream, pluginName); + return kernel.CreatePluginFromGrpc(stream, pluginName, executionParameters); } /// @@ -144,11 +154,13 @@ public static KernelPlugin CreatePluginFromGrpcFile( /// The containing services, plugins, and other state for use throughout the operation. /// .proto document stream. /// Plugin name. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin CreatePluginFromGrpc( this Kernel kernel, Stream documentStream, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { Verify.NotNull(kernel); KernelVerify.ValidPluginName(pluginName, kernel.Plugins); @@ -162,9 +174,13 @@ public static KernelPlugin CreatePluginFromGrpc( ILoggerFactory loggerFactory = kernel.LoggerFactory; - using var client = HttpClientProvider.GetHttpClient(kernel.Services.GetService()); + var client = HttpClientProvider.GetHttpClient(executionParameters?.HttpClient ?? kernel.Services.GetService()); - var runner = new GrpcOperationRunner(client); + var runner = new GrpcOperationRunner( + client, + executionParameters?.AddressOverride, + executionParameters?.AllowedAddresses, + executionParameters?.AllowedSchemes); ILogger logger = loggerFactory.CreateLogger(typeof(GrpcKernelExtensions)) ?? NullLogger.Instance; foreach (var operation in operations) diff --git a/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs b/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs index c4726e649d3d..57e32fac58de 100644 --- a/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs +++ b/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs @@ -22,16 +22,53 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc; /// /// Runs gRPC operation runner. /// -internal sealed class GrpcOperationRunner(HttpClient httpClient) +internal sealed class GrpcOperationRunner { /// Serialization options that use a camel casing naming policy. private static readonly JsonSerializerOptions s_camelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// Deserialization options that use case-insensitive property names. private static readonly JsonSerializerOptions s_propertyCaseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true }; + + private static readonly IReadOnlyList s_defaultAllowedSchemes = ["https"]; + /// /// An instance of the HttpClient class. /// - private readonly HttpClient _httpClient = httpClient; + private readonly HttpClient _httpClient; + + /// + /// Developer-provided address override for the gRPC channel. + /// + private readonly Uri? _addressOverride; + + /// + /// Allowed gRPC server base addresses for SSRF protection. + /// + private readonly IReadOnlyList? _allowedAddresses; + + /// + /// Allowed URI schemes for gRPC server addresses. + /// + private readonly IReadOnlyList _allowedSchemes; + + /// + /// Creates an instance of a class. + /// + /// The HttpClient to use for sending gRPC requests. + /// Optional developer-provided address override. + /// Optional allowed base addresses for SSRF protection. + /// Optional allowed URI schemes. Defaults to https only. + internal GrpcOperationRunner( + HttpClient httpClient, + Uri? addressOverride = null, + IReadOnlyList? allowedAddresses = null, + IReadOnlyList? allowedSchemes = null) + { + this._httpClient = httpClient; + this._addressOverride = addressOverride; + this._allowedAddresses = allowedAddresses; + this._allowedSchemes = allowedSchemes is { Count: > 0 } ? allowedSchemes : s_defaultAllowedSchemes; + } /// /// Runs a gRPC operation. @@ -47,7 +84,7 @@ public async Task RunAsync(GrpcOperation operation, KernelArguments var stringArgument = CastToStringArguments(arguments, operation); - var address = this.GetAddress(operation, stringArgument); + var address = this.GetAddress(operation); var channelOptions = new GrpcChannelOptions { HttpClient = this._httpClient, DisposeHttpClient = false }; @@ -118,11 +155,16 @@ private static JsonObject ConvertResponse(object response, Type responseType) /// Returns address of a channel that provides connection to a gRPC server. /// /// The gRPC operation. - /// The gRPC operation arguments. /// The channel address. - private string GetAddress(GrpcOperation operation, Dictionary arguments) + private string GetAddress(GrpcOperation operation) { - if (!arguments.TryGetValue(GrpcOperation.AddressArgumentName, out string? address)) + string? address; + + if (this._addressOverride is not null) + { + address = this._addressOverride.AbsoluteUri; + } + else { address = operation.Address; } @@ -132,6 +174,48 @@ private string GetAddress(GrpcOperation operation, Dictionary ar throw new KernelException($"No address provided for the '{operation.Name}' gRPC operation."); } + if (!Uri.TryCreate(address, UriKind.Absolute, out var addressUri)) + { + throw new KernelException($"The address '{address}' for the '{operation.Name}' gRPC operation is not a valid absolute URI."); + } + + // Validate scheme + if (!this._allowedSchemes.Contains(addressUri.Scheme, StringComparer.OrdinalIgnoreCase)) + { + throw new KernelException($"The URI scheme '{addressUri.Scheme}' is not allowed for the '{operation.Name}' gRPC operation. Allowed schemes: {string.Join(", ", this._allowedSchemes)}."); + } + + // Validate against allowed addresses + if (this._allowedAddresses is { Count: > 0 }) + { + bool isAllowed = false; + foreach (var allowedAddress in this._allowedAddresses) + { + string allowedUri = allowedAddress.AbsoluteUri; + + if (addressUri.AbsoluteUri.StartsWith(allowedUri, StringComparison.OrdinalIgnoreCase)) + { + // If the allowed URI already ends at a boundary (e.g., trailing '/'), + // or the full URIs match exactly, no further check is needed. + // Otherwise, ensure the next character is a path boundary to prevent + // prefix bypasses (e.g., allowed "https://host/grpc" should not match "https://host/grpcevil"). + int prefixLength = allowedUri.Length; + if (prefixLength >= addressUri.AbsoluteUri.Length || + allowedUri[prefixLength - 1] is '/' || + addressUri.AbsoluteUri[prefixLength] is '/' or '?' or '#') + { + isAllowed = true; + break; + } + } + } + + if (!isAllowed) + { + throw new KernelException($"The address '{address}' is not allowed for the '{operation.Name}' gRPC operation. The address must match one of the allowed base addresses."); + } + } + return address!; } diff --git a/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs b/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs index ee5f25c17c90..321312c5680c 100644 --- a/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs +++ b/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs @@ -9,11 +9,6 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc.Model; /// internal sealed class GrpcOperation { - /// - /// Name of 'address' argument used as override for the address provided by gRPC operation. - /// - internal const string AddressArgumentName = "address"; - /// /// Name of 'payload' argument that represents gRPC operation request message. /// @@ -90,12 +85,6 @@ public string FullServiceName /// The list of parameters. internal static List CreateParameters() => [ - // Register the "address" parameter so that it's possible to override it if needed. - new(GrpcOperation.AddressArgumentName) - { - Description = "Address for gRPC channel to use.", - }, - // Register the "payload" parameter to be used as gRPC operation request message. new(GrpcOperation.PayloadArgumentName) { diff --git a/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs b/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs index 1e564e339a6e..001ea6bbc6d6 100644 --- a/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs @@ -24,7 +24,7 @@ public GrpcOperationExtensionsTests() } [Fact] - public void ThereShouldBeAddressParameter() + public void ThereShouldNotBeAddressParameter() { // Act var parameters = GrpcOperation.CreateParameters(); @@ -34,8 +34,7 @@ public void ThereShouldBeAddressParameter() Assert.NotEmpty(parameters); var addressParameter = parameters.SingleOrDefault(p => p.Name == "address"); - Assert.NotNull(addressParameter); - Assert.Equal("Address for gRPC channel to use.", addressParameter.Description); + Assert.Null(addressParameter); } [Fact] diff --git a/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs index 756ab5ce22fe..8ecdfe10ee0a 100644 --- a/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs @@ -73,7 +73,7 @@ public async Task ShouldUseAddressProvidedInGrpcOperationAsync() } [Fact] - public async Task ShouldUseAddressOverrideFromArgumentsAsync() + public async Task ShouldIgnoreAddressFromArgumentsAsync() { // Arrange this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); @@ -96,15 +96,278 @@ public async Task ShouldUseAddressOverrideFromArgumentsAsync() var arguments = new KernelArguments { { "payload", JsonSerializer.Serialize(new { name = "author" }) }, - { "address", "https://fake-random-test-host-from-args" } + { "address", "https://evil-host-from-llm" } }; // Act var result = await sut.RunAsync(operation, arguments); - // Assert + // Assert - LLM-supplied address should be ignored, operation address should be used Assert.NotNull(this._httpMessageHandlerStub.RequestUri); - Assert.Equal("https://fake-random-test-host-from-args/greet.Greeter/SayHello", this._httpMessageHandlerStub.RequestUri.AbsoluteUri); + Assert.Equal("https://fake-random-test-host/greet.Greeter/SayHello", this._httpMessageHandlerStub.RequestUri.AbsoluteUri); + } + + [Fact] + public async Task ShouldUseAddressOverrideFromParametersAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var addressOverride = new Uri("https://developer-override-host"); + var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://fake-random-test-host" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - developer-provided override takes precedence + Assert.NotNull(this._httpMessageHandlerStub.RequestUri); + Assert.StartsWith("https://developer-override-host/", this._httpMessageHandlerStub.RequestUri.AbsoluteUri); + } + + [Fact] + public async Task ShouldRejectAddressNotInAllowlistAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://allowed-host.com/") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://not-allowed-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("not allowed", ex.Message); + } + + [Fact] + public async Task ShouldAllowAddressInAllowlistAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://fake-random-test-host/") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://fake-random-test-host" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - should succeed + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldAllowAddressWithSubPathWhenAllowlistHasTrailingSlashAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://fake-random-test-host/grpc/") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://fake-random-test-host/grpc/v1" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - trailing slash in allowlist should allow sub-paths + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldAllowHttpWhenCustomSchemesPermitItAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedSchemes = new[] { "https", "http" }; + var sut = new GrpcOperationRunner(this._httpClient, allowedSchemes: allowedSchemes); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "http://localhost" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - http should be allowed when custom schemes permit it + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldRejectNonHttpsSchemeByDefaultAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var sut = new GrpcOperationRunner(this._httpClient); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "http://insecure-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("scheme", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ShouldRejectAddressThatSharesPrefixButIsNotAtPathBoundaryAsync() + { + // Arrange - allowlist without trailing slash, address extends the path segment + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://api.example.com/grpc") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://api.example.com/grpcevil" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert - "grpcevil" should NOT match "grpc" since it's not at a path boundary + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("not allowed", ex.Message); + } + + [Fact] + public async Task ShouldRejectAddressOverrideNotInAllowlistAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://allowed-host.com/") }; + var addressOverride = new Uri("https://evil-host.com/"); + var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://allowed-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert - AddressOverride should also be validated against allowlist + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("not allowed", ex.Message); + } + + [Fact] + public async Task ShouldRejectAddressOverrideWithDisallowedSchemeAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var addressOverride = new Uri("http://insecure-host.com/"); + var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://safe-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert - AddressOverride with http should be rejected when only https is allowed + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("scheme", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] From b7ae840d65c244b8f72e55ef1a5be8bdb4f31ac7 Mon Sep 17 00:00:00 2001 From: Cozmopolit <123839573+Cozmopolit@users.noreply.github.com> Date: Thu, 7 May 2026 23:01:31 +0200 Subject: [PATCH 22/32] .Net: feat(connectors): Support ImageContent in tool/function results (#13431) ### Motivation and Context Fixes #13430 Currently, when a Semantic Kernel function returns `ImageContent`, it gets serialized to JSON - losing the binary image data and preventing multimodal-capable models from processing the image. This PR enables `ImageContent` preservation in tool/function results, allowing connectors with multimodal capabilities (Gemini 3+, Anthropic) to pass images natively to the model. This is essential for agentic workflows where tools generate or process images that the model needs to analyze. ### Description #### FunctionCallsProcessor (shared infrastructure) - Changed `ProcessFunctionResult()` return type from `string` to `object` - Added early return for `ImageContent` to preserve it for multimodal-capable connectors - Added `ImageContentNotSupportedErrorMessage` constant for consistent error messaging #### Google Gemini Connector (native support) - Extended `FunctionResponsePart` with `Parts` property for nested multimodal content - Added `FunctionResponsePartContent` class with `InlineData` support - Implemented `CreateImageFunctionResponsePart()` to convert `ImageContent` to Gemini's native `inlineData` format #### OpenAI Connector (error handling) - Added `ImageContent` check with clear error message (API does not support images in tool results) #### OpenAI Agents (error handling) - Added `GetFunctionResultAsString()` helper with `ImageContent` error handling #### Amazon Bedrock Agents (error handling) - Added `GetFunctionResultAsString()` helper with `ImageContent` error handling #### New Unit Tests | Test | File | |------|------| | `ItShouldPreserveImageContentWithoutSerialization` | `FunctionCallsProcessorTests.cs` | | `FromChatHistoryImageContentInToolResultCreatesInlineDataPart` | `GeminiRequestTests.cs` | | `FromChatHistoryImageContentWithoutDataThrowsInvalidOperationException` | `GeminiRequestTests.cs` | | `FromChatHistoryImageContentWithoutMimeTypeThrowsInvalidOperationException` | `GeminiRequestTests.cs` | | `VerifyAssistantMessageAdapterGetMessageWithImageContentInFunctionResult` | `AssistantMessageFactoryTests.cs` | #### Notes - **No breaking changes**: All changes are to internal APIs. The public `FunctionResultContent.Result` property is already `object?`. - **Gemini version detection**: The connector does not check the model version. If an older Gemini model does not support `functionResponse.parts`, the API will return an appropriate error. - **URI-based ImageContent**: Only `ImageContent` with binary data is supported. URI-based `ImageContent` will throw `InvalidOperationException`. - **MistralAI**: Out of scope - has its own `ProcessFunctionResult` implementation. - **Related**: Prepares infrastructure for PR #13419 (Anthropic Connector multimodal support) ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: [sk-pr-MultimodalToolResults.md](https://github.com/user-attachments/files/24299998/sk-pr-MultimodalToolResults.md) --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../BedrockAgentInvokeExtensions.cs | 18 ++++- .../Internal/AssistantMessageFactory.cs | 18 ++++- .../OpenAI/Internal/ResponseThreadActions.cs | 20 +++++- .../Internal/AssistantMessageFactoryTests.cs | 25 +++++++ .../Internal/ResponseThreadActionsTests.cs | 64 ++++++++++++++++++ .../Core/Gemini/GeminiRequestTests.cs | 65 +++++++++++++++++++ .../Core/Gemini/Models/GeminiPart.cs | 19 ++++++ .../Core/Gemini/Models/GeminiRequest.cs | 54 ++++++++++++++- .../OpenAIChatCompletionServiceTests.cs | 45 +++++++++++++ .../Core/ClientCore.ChatCompletion.cs | 11 +++- .../FunctionCalling/FunctionCallsProcessor.cs | 39 ++++++++--- .../FunctionCallsProcessorTests.cs | 18 +++++ 12 files changed, 378 insertions(+), 18 deletions(-) create mode 100644 dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs diff --git a/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs b/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs index 5bdc214b4df7..b23c15dfcad1 100644 --- a/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs +++ b/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs @@ -214,7 +214,7 @@ private static SessionState CreateSessionStateWithFunctionResults(List { - { "TEXT", new ContentBody() { Body = FunctionCallsProcessor.ProcessFunctionResult(functionResult.Result ?? string.Empty) } } + { "TEXT", new ContentBody() { Body = GetFunctionResultAsString(functionResult.Result) } } } } }; @@ -222,4 +222,20 @@ private static SessionState CreateSessionStateWithFunctionResults(List + /// Processes a function result and returns a string representation. + /// Bedrock does not support multimodal tool results, so ImageContent returns an error message. + /// + private static string GetFunctionResultAsString(object? result) + { + var processed = FunctionCallsProcessor.ProcessFunctionResult(result ?? string.Empty); + + if (processed is ImageContent) + { + return FunctionCallsProcessor.ImageContentNotSupportedErrorMessage; + } + + return (string?)processed ?? string.Empty; + } } diff --git a/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs b/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs index 008e781fafa8..866eac8d88c5 100644 --- a/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs +++ b/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs @@ -71,8 +71,24 @@ public static IEnumerable GetMessageContents(ChatMessageContent else if (content is FunctionResultContent resultContent && resultContent.Result != null && !hasTextContent) { // Only convert a function result when text-content is not already present - yield return MessageContent.FromText(FunctionCallsProcessor.ProcessFunctionResult(resultContent.Result)); + yield return MessageContent.FromText(GetFunctionResultAsString(resultContent.Result)); } } } + + /// + /// Processes a function result and returns a string representation. + /// OpenAI Assistants do not support multimodal tool results, so ImageContent returns an error message. + /// + private static string GetFunctionResultAsString(object result) + { + var processed = FunctionCallsProcessor.ProcessFunctionResult(result); + + if (processed is ImageContent) + { + return FunctionCallsProcessor.ImageContentNotSupportedErrorMessage; + } + + return (string?)processed ?? string.Empty; + } } diff --git a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs index f9fc29120fc3..2cbddfddc721 100644 --- a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs +++ b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs @@ -93,7 +93,7 @@ await functionProcessor.InvokeFunctionCallsAsync( agent.GetKernel(options), isStreaming: false, cancellationToken).ConfigureAwait(false); - var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, fr.Result?.ToString() ?? string.Empty)).ToList(); + var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, GetFunctionResultAsString(fr.Result))).ToList(); // If store is enabled we only need to send the function output items if (agent.StoreEnabled) @@ -267,7 +267,7 @@ await functionProcessor.InvokeFunctionCallsAsync( agent.GetKernel(options), isStreaming: true, cancellationToken).ConfigureAwait(false); - var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, fr.Result?.ToString() ?? string.Empty)).ToList(); + var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, GetFunctionResultAsString(fr.Result))).ToList(); // If store is enabled we only need to send the function output items if (agent.StoreEnabled) @@ -318,6 +318,22 @@ private static void ThrowIfIncompleteOrFailed(OpenAIResponseAgent agent, Respons } } + /// + /// Processes a function result and returns a string representation. + /// The OpenAI Responses API does not support multimodal tool results, so ImageContent returns an error message. + /// + internal static string GetFunctionResultAsString(object? result) + { + var processed = FunctionCallsProcessor.ProcessFunctionResult(result ?? string.Empty); + + if (processed is ImageContent) + { + return FunctionCallsProcessor.ImageContentNotSupportedErrorMessage; + } + + return (string?)processed ?? string.Empty; + } + /// POCO representing function calling info. /// Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo(FunctionCallResponseItem item) diff --git a/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs index 85d843465b3b..eebf29e8dffe 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI.Internal; using Microsoft.SemanticKernel.ChatCompletion; + using OpenAI.Assistants; using Xunit; @@ -207,4 +208,28 @@ public void VerifyAssistantMessageAdapterGetMessageWithAll() Assert.NotNull(contents); Assert.Equal(3, contents.Length); } + + /// + /// Verify that ImageContent in FunctionResultContent returns error message + /// since OpenAI Assistants do not support multimodal tool results. + /// + [Fact] + public void VerifyAssistantMessageAdapterGetMessageWithImageContentInFunctionResult() + { + // Arrange: Create a FunctionResultContent containing ImageContent + var imageData = new ReadOnlyMemory([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes + var imageContent = new ImageContent(imageData, "image/png"); + var functionResultContent = new FunctionResultContent("TestFunction", "TestPlugin", "call-id", imageContent); + ChatMessageContent message = new(AuthorRole.Tool, items: [functionResultContent]); + + // Act + MessageContent[] contents = AssistantMessageFactory.GetMessageContents(message).ToArray(); + + // Assert: Should return error message since OpenAI Assistants don't support multimodal tool results + Assert.NotNull(contents); + Assert.Single(contents); + Assert.NotNull(contents.Single().Text); + // Expected error message from FunctionCallsProcessor.ImageContentNotSupportedErrorMessage + Assert.Equal("Error: This model does not support image content in tool results.", contents.Single().Text); + } } diff --git a/dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs new file mode 100644 index 000000000000..8043250638df --- /dev/null +++ b/dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents.OpenAI.Internal; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.OpenAI.Internal; + +/// +/// Unit tests for . +/// +public class ResponseThreadActionsTests +{ + /// + /// Verify that returns the + /// shared ImageContentNotSupportedErrorMessage when the function result is an + /// , since the OpenAI Responses API does not support multimodal tool results. + /// + [Fact] + public void VerifyResponseThreadActionsGetFunctionResultAsStringReturnsErrorMessageForImageContent() + { + // Arrange: Create an ImageContent with binary data + var imageData = new ReadOnlyMemory([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes + var imageContent = new ImageContent(imageData, "image/png"); + + // Act + string result = ResponseThreadActions.GetFunctionResultAsString(imageContent); + + // Assert + Assert.Equal("Error: This model does not support image content in tool results.", result); + } + + /// + /// Verify that returns the + /// original string verbatim when the function result is a string. + /// + [Fact] + public void VerifyResponseThreadActionsGetFunctionResultAsStringReturnsStringVerbatim() + { + // Arrange + const string Expected = "tool result text"; + + // Act + string result = ResponseThreadActions.GetFunctionResultAsString(Expected); + + // Assert + Assert.Equal(Expected, result); + } + + /// + /// Verify that returns + /// when the function result is . + /// + [Fact] + public void VerifyResponseThreadActionsGetFunctionResultAsStringReturnsEmptyForNull() + { + // Act + string result = ResponseThreadActions.GetFunctionResultAsString(null); + + // Assert + Assert.Equal(string.Empty, result); + } +} diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs index c1492b79ed64..fefd1ab0cb61 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs @@ -781,6 +781,71 @@ public void FromChatHistoryMultiTurnConversationPreservesAllRoles() Assert.Equal("assistant-message-2", request.Contents[3].Parts![0].Text); } + [Fact] + public void FromChatHistoryImageContentInToolResultCreatesInlineDataPart() + { + // Arrange + ChatHistory chatHistory = []; + var imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes + var imageContent = new ImageContent(imageBytes, "image/png"); + var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => imageContent); + var toolCall = new GeminiFunctionToolCall(new GeminiPart.FunctionCallPart { FunctionName = "capture-screenshot" }); + GeminiFunctionToolResult toolCallResult = new(toolCall, new FunctionResult(kernelFunction, imageContent)); + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Tool, string.Empty, "modelId", toolCallResult)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Contents); + var part = request.Contents[0].Parts![0]; + Assert.NotNull(part.FunctionResponse); + Assert.Equal("capture-screenshot", part.FunctionResponse.FunctionName); + Assert.NotNull(part.FunctionResponse.Parts); + Assert.Single(part.FunctionResponse.Parts); + Assert.NotNull(part.FunctionResponse.Parts[0].InlineData); + Assert.Equal("image/png", part.FunctionResponse.Parts[0].InlineData!.MimeType); + Assert.Equal(Convert.ToBase64String(imageBytes), part.FunctionResponse.Parts[0].InlineData!.InlineData); + } + + [Fact] + public void FromChatHistoryImageContentWithoutDataThrowsInvalidOperationException() + { + // Arrange + ChatHistory chatHistory = []; + var imageContent = new ImageContent(new Uri("https://example.com/image.png")) { MimeType = "image/png" }; + var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => imageContent); + var toolCall = new GeminiFunctionToolCall(new GeminiPart.FunctionCallPart { FunctionName = "capture-screenshot" }); + GeminiFunctionToolResult toolCallResult = new(toolCall, new FunctionResult(kernelFunction, imageContent)); + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Tool, string.Empty, "modelId", toolCallResult)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act & Assert + var exception = Assert.Throws( + () => GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings)); + Assert.Equal("ImageContent in function result must contain binary data.", exception.Message); + } + + [Fact] + public void FromChatHistoryImageContentWithoutMimeTypeThrowsInvalidOperationException() + { + // Arrange + ChatHistory chatHistory = []; + ReadOnlyMemory imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; + var imageContent = new ImageContent(imageBytes, mimeType: null); // No MimeType + var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => imageContent); + var toolCall = new GeminiFunctionToolCall(new GeminiPart.FunctionCallPart { FunctionName = "capture-screenshot" }); + GeminiFunctionToolResult toolCallResult = new(toolCall, new FunctionResult(kernelFunction, imageContent)); + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Tool, string.Empty, "modelId", toolCallResult)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act & Assert + var exception = Assert.Throws( + () => GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings)); + Assert.Equal("Image content MimeType is empty.", exception.Message); + } + [Fact] public void FromChatHistoryToolCallsWithThoughtSignatureIncludesSignatureInRequest() { diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs index d49c196da744..4ef4c71c097a 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs @@ -185,6 +185,14 @@ internal sealed class FunctionResponsePart [JsonRequired] public FunctionResponseEntity Response { get; set; } = null!; + /// + /// Optional. Nested parts for multimodal function responses (Gemini 3+ only). + /// Contains inlineData with image/binary data as part of tool results. + /// + [JsonPropertyName("parts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FunctionResponsePartContent[]? Parts { get; set; } + internal sealed class FunctionResponseEntity { [JsonConstructor] @@ -202,5 +210,16 @@ public FunctionResponseEntity(object? response) [JsonRequired] public JsonNode Arguments { get; set; } = null!; } + + /// + /// Represents a part within a Gemini function response (for multimodal content). + /// Used in Gemini 3+ to include images/binary data as part of tool results. + /// + internal sealed class FunctionResponsePartContent + { + [JsonPropertyName("inlineData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public InlineDataPart? InlineData { get; set; } + } } } diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs index cd15974a886c..ffa2af9edcd3 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs @@ -23,6 +23,13 @@ internal sealed class GeminiRequest } }; + /// + /// Synthetic envelope used as the functionResponse.response body when emitting a multimodal + /// (image) tool result. The actual image data is carried in functionResponse.parts[].inlineData; + /// the envelope keeps the required response field present and gives the model a short hint. + /// + private static readonly object s_imageFunctionResponseEnvelope = new { status = "success", message = "Image data attached" }; + [JsonPropertyName("contents")] public IList Contents { get; set; } = null!; @@ -194,14 +201,24 @@ private static List CreateGeminiParts(ChatMessageContent content) case GeminiChatMessageContent { CalledToolResults: not null } contentWithCalledTools: // Add all function responses as separate parts in a single message parts.AddRange(contentWithCalledTools.CalledToolResults.Select(toolResult => - new GeminiPart + { + var resultValue = toolResult.FunctionResult.GetValue(); + + // Handle ImageContent for multimodal tool results (Gemini 3+ only) + if (resultValue is ImageContent imageContent) + { + return CreateImageFunctionResponsePart(toolResult.FullyQualifiedName, imageContent); + } + + return new GeminiPart { FunctionResponse = new GeminiPart.FunctionResponsePart { FunctionName = toolResult.FullyQualifiedName, - Response = new(toolResult.FunctionResult.GetValue()) + Response = new(resultValue) } - })); + }; + })); break; case GeminiChatMessageContent { ToolCalls: not null } contentWithToolCalls: parts.AddRange(contentWithToolCalls.ToolCalls.Select(toolCall => @@ -302,6 +319,37 @@ private static string GetMimeTypeFromImageContent(ImageContent imageContent) ?? throw new InvalidOperationException("Image content MimeType is empty."); } + /// + /// Creates a GeminiPart with FunctionResponse containing multimodal image data (Gemini 3+ only). + /// + private static GeminiPart CreateImageFunctionResponsePart(string functionName, ImageContent imageContent) + { + if (imageContent.Data is not { IsEmpty: false }) + { + throw new InvalidOperationException("ImageContent in function result must contain binary data."); + } + + return new GeminiPart + { + FunctionResponse = new GeminiPart.FunctionResponsePart + { + FunctionName = functionName, + Response = new(s_imageFunctionResponseEnvelope), + Parts = + [ + new GeminiPart.FunctionResponsePart.FunctionResponsePartContent + { + InlineData = new GeminiPart.InlineDataPart + { + MimeType = GetMimeTypeFromImageContent(imageContent), + InlineData = Convert.ToBase64String(imageContent.Data.Value.ToArray()) + } + } + ] + } + }; + } + private static GeminiPart CreateGeminiPartFromAudio(AudioContent audioContent) { // Binary data takes precedence over URI. diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs index 397c9bb0e39d..57b7700a0595 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs @@ -1662,6 +1662,51 @@ public async Task ItSendsEmptyStringWhenAssistantMessageContentIsNull() Assert.Equal(string.Empty, assistantMessageContent); } + [Fact] + public async Task ItSendsImageContentNotSupportedErrorWhenToolResultIsImageContentAsync() + { + // Arrange + var chatCompletion = new OpenAIChatCompletionService(modelId: "any", apiKey: "NOKEY", httpClient: this._httpClient); + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(ChatCompletionResponse) + }; + + List assistantToolCalls = [ChatToolCall.CreateFunctionToolCall("call-id", "GetImage", BinaryData.FromString("{}"))]; + + var imageBytes = new ReadOnlyMemory([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes + var imageContent = new ImageContent(imageBytes, "image/png"); + + var chatHistory = new ChatHistory() + { + new ChatMessageContent(role: AuthorRole.User, content: "Show me the image", modelId: "any"), + new ChatMessageContent(role: AuthorRole.Assistant, content: null, modelId: "any", metadata: new Dictionary + { + ["ChatResponseMessage.FunctionToolCalls"] = assistantToolCalls + }), + new ChatMessageContent(role: AuthorRole.Tool, content: null, modelId: "any") + { + Items = [new FunctionResultContent("GetImage", "ImagePlugin", "call-id", imageContent)] + }, + }; + + // Act + await chatCompletion.GetChatMessageContentsAsync(chatHistory, this._executionSettings); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + Assert.NotNull(actualRequestContent); + + var requestContent = JsonElement.Parse(actualRequestContent); + var messages = requestContent.GetProperty("messages").EnumerateArray().ToList(); + + var toolMessage = messages.First(message => message.GetProperty("role").GetString() == "tool"); + var toolMessageContent = toolMessage.GetProperty("content").GetString(); + + // OpenAI does not support multimodal tool results - expect the standard error message + Assert.Equal("Error: This model does not support image content in tool results.", toolMessageContent); + } + [Theory] [MemberData(nameof(WebSearchOptionsData))] public async Task ItCreatesCorrectWebSearchOptionsAsync(object webSearchOptions, string expectedJson) diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs index 7f8dfa41a2d9..88ace29aff4d 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs @@ -767,9 +767,16 @@ private static List CreateRequestMessages(ChatMessageContent messag continue; } - var stringResult = FunctionCalling.FunctionCallsProcessor.ProcessFunctionResult(resultContent.Result ?? string.Empty); + var result = FunctionCalling.FunctionCallsProcessor.ProcessFunctionResult(resultContent.Result ?? string.Empty); - toolMessages.Add(new ToolChatMessage(resultContent.CallId, stringResult ?? string.Empty)); + // OpenAI does not support multimodal tool results - return error message for ImageContent + if (result is ImageContent) + { + toolMessages.Add(new ToolChatMessage(resultContent.CallId, FunctionCalling.FunctionCallsProcessor.ImageContentNotSupportedErrorMessage)); + continue; + } + + toolMessages.Add(new ToolChatMessage(resultContent.CallId, (string?)result ?? string.Empty)); } if (toolMessages is not null) diff --git a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs index ddca98c7c053..06c943637be0 100644 --- a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs +++ b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs @@ -42,6 +42,11 @@ internal sealed class FunctionCallsProcessor /// private const int MaxInflightAutoInvokes = 128; + /// + /// Error message returned when a connector does not support ImageContent in tool results. + /// + public const string ImageContentNotSupportedErrorMessage = "Error: This model does not support image content in tool results."; + /// /// The maximum number of function auto-invokes that can be made in a single user request. /// @@ -340,7 +345,7 @@ private static bool TryValidateFunctionCall( return false; } - private record struct FunctionResultContext(AutoFunctionInvocationContext Context, FunctionCallContent FunctionCall, string? Result, string? ErrorMessage); + private record struct FunctionResultContext(AutoFunctionInvocationContext Context, FunctionCallContent FunctionCall, object? Result, string? ErrorMessage); private async Task ExecuteFunctionCallAsync( AutoFunctionInvocationContext invocationContext, @@ -377,8 +382,8 @@ await this.OnAutoFunctionInvocationAsync( } // Apply any changes from the auto function invocation filters context to final result. - string stringResult = ProcessFunctionResult(invocationContext.Result.GetValue() ?? string.Empty); - return new FunctionResultContext(invocationContext, functionCall, stringResult, null); + object result = ProcessFunctionResult(invocationContext.Result.GetValue() ?? string.Empty); + return new FunctionResultContext(invocationContext, functionCall, result, null); } /// @@ -388,7 +393,8 @@ await this.OnAutoFunctionInvocationAsync( /// The function result context. private void AddFunctionCallResultToChatHistory(ChatHistory chatHistory, FunctionResultContext resultContext) { - var message = new ChatMessageContent(role: AuthorRole.Tool, content: resultContext.Result); + // When Result is ImageContent, Content will be null - the actual result is in FunctionResultContent.Result + var message = new ChatMessageContent(role: AuthorRole.Tool, content: resultContext.Result as string); message.Items.Add(this.GenerateResultContent(resultContext)); chatHistory.Add(message); } @@ -419,9 +425,9 @@ private FunctionResultContent GenerateResultContent(FunctionResultContext result /// Creates a instance. /// /// The function call content. - /// The function result, if available + /// The function result, if available. Can be string or ImageContent. /// An error message. - private FunctionResultContent GenerateResultContent(FunctionCallContent functionCall, string? result, string? errorMessage) + private FunctionResultContent GenerateResultContent(FunctionCallContent functionCall, object? result, string? errorMessage) { // Log any error if (errorMessage is not null) @@ -429,6 +435,7 @@ private FunctionResultContent GenerateResultContent(FunctionCallContent function this._logger.LogFunctionCallRequestFailure(functionCall, errorMessage); } + // FunctionResultContent.Result is object? - pass through string or ImageContent directly return new FunctionResultContent(functionCall.FunctionName, functionCall.PluginName, functionCall.Id, result ?? errorMessage ?? string.Empty); } @@ -478,17 +485,31 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } /// - /// Processes the function result. + /// Processes the function result into the form a connector should serialize into a tool/function response. /// /// The result of the function call. - /// A string representation of the function result. - public static string ProcessFunctionResult(object functionResult) + /// + /// One of: + /// + /// The original when is a string. + /// The original instance when is an , so multimodal-capable connectors (e.g., Gemini 3+) can attach it natively. Connectors that do not support multimodal tool results must detect this case and substitute . + /// A JSON-serialized representation of any other type (with and short-circuited to their text form). + /// + /// + public static object ProcessFunctionResult(object functionResult) { if (functionResult is string stringResult) { return stringResult; } + // Preserve ImageContent for connectors that support multimodal tool results (e.g., Gemini 3+, Anthropic) + // Connectors that don't support this should check for ImageContent and return an appropriate error message. + if (functionResult is ImageContent) + { + return functionResult; + } + // This is an optimization to use ChatMessageContent content directly // without unnecessary serialization of the whole message content class. if (functionResult is ChatMessageContent chatMessageContent) diff --git a/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs index e1258d124c6a..1fc60b79e880 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs @@ -855,6 +855,24 @@ public void ItShouldSerializeFunctionResultsWithStringProperties() Assert.Equal("{\"Text\":\"テスト\"}", result); } + [Fact] + public void ItShouldPreserveImageContentWithoutSerialization() + { + // Arrange + var imageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes + var functionResult = new ImageContent(imageData, "image/png"); + + // Act + var result = FunctionCallsProcessor.ProcessFunctionResult(functionResult); + + // Assert + Assert.IsType(result); + var imageResult = (ImageContent)result; + Assert.Equal("image/png", imageResult.MimeType); + Assert.NotNull(imageResult.Data); + Assert.Equal(imageData, imageResult.Data.Value.ToArray()); + } + [Fact] public async Task ItShouldPassPromptExecutionSettingsToAutoFunctionInvocationFilterAsync() { From 446c2eff94bcf4539e1528db692352bbbb3628a5 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 8 May 2026 12:26:50 +0100 Subject: [PATCH 23/32] .Net: Improve input validation in OpenAPI plugin (#13962) Strengthen input validation for server variable substitution and path parameter handling in RestApiOperation. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Model/RestApiOperation.cs | 26 ++- .../OpenApi/RestApiOperationTests.cs | 178 ++++++++++++++++++ 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs index c2a5919514c4..5bfa5a8a1ff2 100644 --- a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs +++ b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs @@ -308,6 +308,8 @@ private string BuildPath(string pathTemplate, IDictionary argum pathTemplate = pathTemplate.Replace($"{{{parameter.Name}}}", HttpUtility.UrlEncode(serializer.Invoke(parameter, node))); } + ValidatePathSegments(pathTemplate); + return pathTemplate; } @@ -364,19 +366,19 @@ private Uri GetServerUrl(Uri? serverUrlOverride, Uri? apiHostUrl, IDictionary + /// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal. + /// ".." navigates up one path segment, enabling traversal to unintended endpoints. + /// "." refers to the current directory — harmless but unexpected, so rejected to prevent misuse. + /// + /// The path to validate. + private static void ValidatePathSegments(string path) + { + var segments = path.Split('/'); + for (int i = 0; i < segments.Length; i++) + { + if (segments[i] == "." || segments[i] == "..") + { + throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal."); + } + } + } + private IDictionary _extensions = s_emptyDictionary; private readonly Freezable _freezable = new(); private string? _description; diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs index c90869bc8912..9b17ae442731 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs @@ -1315,4 +1315,182 @@ public void ItShouldFreezeModifiableProperties() Assert.Throws(() => sut.Extensions.Add("x-fake", "fake_value")); } + + [Fact] + public void ItShouldEncodeServerVariableValuesFromArguments() + { + // Arrange — variable value contains path-manipulation characters + var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]); + var sut = new RestApiOperation( + id: "fake_id", + servers: [ + new RestApiServer("https://example.com/{version}", new Dictionary { { "version", version } }), + ], + path: "/items", + method: HttpMethod.Get, + description: "fake_description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary() { { "version", "v2/../admin" } }; + + // Act + var url = sut.BuildOperationUrl(arguments); + + // Assert — reserved separators (/) must be percent-encoded so they are not interpreted as path delimiters + Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString); + } + + [Fact] + public void ItShouldPreventServerVariableInjectionWithSpecialCharacters() + { + // Arrange — variable value contains path traversal and query string injection + var host = new RestApiServerVariable("api.example.com"); + var sut = new RestApiOperation( + id: "fake_id", + servers: [ + new RestApiServer("https://{host}/api", new Dictionary { { "host", host } }), + ], + path: "/data", + method: HttpMethod.Get, + description: "fake_description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary() { { "host", "evil.com/hijack?q=1#" } }; + + // Act & Assert — encoding turns /, ?, # into percent-encoded sequences (%2F, %3F, %23), + // which prevents them from being interpreted as structural URI delimiters. + // The Uri constructor rejects the resulting hostname, which is the desired outcome. + Assert.ThrowsAny(() => sut.BuildOperationUrl(arguments)); + } + + [Fact] + public void ItShouldRejectDotSegmentInPathParameter() + { + // Arrange — path parameter value is ".." (dot-segment traversal) + var parameters = new List { + new( + name: "id", + type: "string", + isRequired: true, + expand: false, + location: RestApiParameterLocation.Path, + style: RestApiParameterStyle.Simple) + }; + + var sut = new RestApiOperation( + id: "fake_id", + servers: [new RestApiServer("https://example.com/api")], + path: "/resources/{id}/details", + method: HttpMethod.Get, + description: "fake_description", + parameters: parameters, + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary { { "id", ".." } }; + + // Act & Assert — dot-segments must be rejected + var ex = Assert.Throws(() => sut.BuildOperationUrl(arguments)); + Assert.Contains("dot-segment", ex.Message); + } + + [Fact] + public void ItShouldRejectSingleDotSegmentInPathParameter() + { + // Arrange — path parameter value is "." (single-dot segment) + var parameters = new List { + new( + name: "id", + type: "string", + isRequired: true, + expand: false, + location: RestApiParameterLocation.Path, + style: RestApiParameterStyle.Simple) + }; + + var sut = new RestApiOperation( + id: "fake_id", + servers: [new RestApiServer("https://example.com/api")], + path: "/resources/{id}/details", + method: HttpMethod.Get, + description: "fake_description", + parameters: parameters, + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary { { "id", "." } }; + + // Act & Assert — single-dot segments must also be rejected + var ex = Assert.Throws(() => sut.BuildOperationUrl(arguments)); + Assert.Contains("dot-segment", ex.Message); + } + + [Fact] + public void ItShouldAllowDotsInNonSegmentPathParameterValues() + { + // Arrange — path parameter contains dots but is NOT a dot-segment (e.g., "file.txt") + var parameters = new List { + new( + name: "filename", + type: "string", + isRequired: true, + expand: false, + location: RestApiParameterLocation.Path, + style: RestApiParameterStyle.Simple) + }; + + var sut = new RestApiOperation( + id: "fake_id", + servers: [new RestApiServer("https://example.com/api")], + path: "/files/{filename}", + method: HttpMethod.Get, + description: "fake_description", + parameters: parameters, + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary { { "filename", "report.v2.txt" } }; + + // Act + var url = sut.BuildOperationUrl(arguments); + + // Assert — dots within normal filenames should work fine + Assert.Equal("https://example.com/api/files/report.v2.txt", url.OriginalString); + } + + [Fact] + public void ItShouldEncodeServerVariableValuesLookedUpByArgumentName() + { + // Arrange — variable uses ArgumentName and the argument contains path-manipulation characters + var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]) { ArgumentName = "alt_version" }; + var sut = new RestApiOperation( + id: "fake_id", + servers: [ + new RestApiServer("https://example.com/{version}", new Dictionary { { "version", version } }), + ], + path: "/items", + method: HttpMethod.Get, + description: "fake_description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary() { { "alt_version", "v2/../admin" } }; + + // Act + var url = sut.BuildOperationUrl(arguments); + + // Assert — reserved separators must be percent-encoded even when looked up via ArgumentName + Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString); + } } From 3dd139b2bd4000dd8efc19edd6d3127e17916550 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 8 May 2026 12:27:22 +0100 Subject: [PATCH 24/32] .Net: Harden CloudDrivePlugin defaults and add path validation (#13958) ### Description - Improve default settings in CloudDrivePlugin - Add path validation for share-link operations - Improve path canonicalization - Add regression tests ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] The existing tests pass - [x] New tests added --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Plugins.MsGraph/CloudDrivePlugin.cs | 201 +++++++++- .../Connectors/OneDriveConnector.cs | 2 +- .../Plugins.MsGraph/ICloudDriveConnector.cs | 2 +- .../MsGraph/CloudDrivePluginTests.cs | 375 +++++++++++++++++- 4 files changed, 544 insertions(+), 36 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs b/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs index 374bdd4ef1de..17caeb44227d 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs +++ b/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs @@ -18,12 +18,15 @@ namespace Microsoft.SemanticKernel.Plugins.MsGraph; /// /// /// -/// This plugin is secure by default. must be explicitly configured -/// before any file upload operations are permitted. By default, all local file paths are denied. +/// This plugin is secure by default. , , +/// , and must be explicitly configured +/// before file upload, read, or share-link operations are permitted. +/// By default, all paths are denied. /// /// /// When exposing this plugin to an LLM via auto function calling, ensure that -/// is restricted to trusted values only. +/// , , , +/// and are restricted to trusted values only. /// /// public sealed class CloudDrivePlugin @@ -31,6 +34,9 @@ public sealed class CloudDrivePlugin private readonly ICloudDriveConnector _connector; private readonly ILogger _logger; private HashSet _allowedUploadDirectories = []; + private HashSet _allowedSharePaths = []; + private HashSet _allowedReadPaths = []; + private HashSet _allowedUploadDestinationPaths = []; /// /// Initializes a new instance of the class. @@ -59,6 +65,57 @@ public IEnumerable AllowedUploadDirectories set => this._allowedUploadDirectories = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); } + /// + /// List of allowed remote directory prefixes for which sharing links may be created. + /// A file is permitted if its parent directory starts with (or equals) any entry in this list. + /// Subdirectories of allowed paths are also permitted. + /// + /// + /// Defaults to an empty collection (no paths allowed). Must be explicitly populated + /// with trusted remote directory paths before any share-link operations will succeed. + /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison. + /// Matching is case-insensitive (OneDrive paths are case-insensitive). + /// + public IEnumerable AllowedSharePaths + { + get => this._allowedSharePaths; + set => this._allowedSharePaths = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + + /// + /// List of allowed remote directory prefixes from which file contents may be read. + /// A file is permitted if its parent directory starts with (or equals) any entry in this list. + /// Subdirectories of allowed paths are also permitted. + /// + /// + /// Defaults to an empty collection (no paths allowed). Must be explicitly populated + /// with trusted remote directory paths before any read operations will succeed. + /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison. + /// Matching is case-insensitive (OneDrive paths are case-insensitive). + /// + public IEnumerable AllowedReadPaths + { + get => this._allowedReadPaths; + set => this._allowedReadPaths = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + + /// + /// List of allowed remote directory prefixes to which files may be uploaded. + /// A destination is permitted if its parent directory starts with (or equals) any entry in this list. + /// Subdirectories of allowed paths are also permitted. + /// + /// + /// Defaults to an empty collection (no paths allowed). Must be explicitly populated + /// with trusted remote directory paths before any upload-destination operations will succeed. + /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison. + /// Matching is case-insensitive (OneDrive paths are case-insensitive). + /// + public IEnumerable AllowedUploadDestinationPaths + { + get => this._allowedUploadDestinationPaths; + set => this._allowedUploadDestinationPaths = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + /// /// Get the contents of a file stored in a cloud drive. /// @@ -71,6 +128,14 @@ public IEnumerable AllowedUploadDirectories CancellationToken cancellationToken = default) { this._logger.LogDebug("Getting file content for '{0}'", filePath); + + Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); + + if (!this.IsAllowedRemotePath(filePath, this._allowedReadPaths)) + { + throw new InvalidOperationException("Reading from the provided path is not allowed. Configure 'AllowedReadPaths' with trusted remote paths to enable reading."); + } + Stream? fileContentStream = await this._connector.GetFileContentStreamAsync(filePath, cancellationToken).ConfigureAwait(false); if (fileContentStream is null) @@ -104,9 +169,14 @@ public async Task UploadFileAsync( throw new ArgumentException("Variable was null or whitespace", nameof(destinationPath)); } + if (!this.IsAllowedRemotePath(destinationPath, this._allowedUploadDestinationPaths)) + { + throw new InvalidOperationException("Uploading to the provided remote destination is not allowed. Configure 'AllowedUploadDestinationPaths' with trusted remote paths to enable uploads."); + } + Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); - var canonicalPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(filePath)); + var canonicalPath = CanonicalizePath(filePath); if (!this.IsUploadPathAllowed(canonicalPath)) { @@ -131,8 +201,15 @@ public async Task CreateLinkAsync( CancellationToken cancellationToken = default) { this._logger.LogDebug("Creating link for '{0}'", filePath); - const string Type = "view"; // TODO expose this as an SK variable - const string Scope = "anonymous"; // TODO expose this as an SK variable + const string Type = "view"; + const string Scope = "organization"; + + Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); + + if (!this.IsAllowedRemotePath(filePath, this._allowedSharePaths)) + { + throw new InvalidOperationException("Creating a share link for the provided path is not allowed. Configure 'AllowedSharePaths' with trusted remote paths to enable sharing."); + } return await this._connector.CreateShareLinkAsync(filePath, Type, Scope, cancellationToken).ConfigureAwait(false); } @@ -145,24 +222,116 @@ public async Task CreateLinkAsync( : StringComparison.Ordinal; /// - /// If a list of allowed upload directories has been provided, the directory of the provided filePath is checked - /// to verify it is in the allowed directory list. Paths are canonicalized before comparison. - /// Subdirectories of allowed directories are also permitted. + /// Checks whether the provided remote path falls within one of the allowed remote directory prefixes. + /// Paths are normalized with forward slashes, dot-segments are collapsed, + /// and compared case-insensitively (OneDrive paths are case-insensitive). + /// Subdirectories of allowed paths are permitted. /// - private bool IsUploadPathAllowed(string path) + private bool IsAllowedRemotePath(string path, HashSet allowedPaths) { Ensure.NotNullOrWhitespace(path, nameof(path)); - if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase)) + if (allowedPaths.Count == 0) + { + return false; + } + + // Normalize to forward slashes and collapse dot-segments to prevent traversal bypass. + var normalizedPath = NormalizeRemotePath(path); + + foreach (var allowedPath in allowedPaths) + { + var normalizedAllowed = NormalizeRemotePath(allowedPath); + if (!normalizedAllowed.EndsWith("/", StringComparison.Ordinal)) + { + normalizedAllowed += "/"; + } + + var normalizedDir = normalizedPath; + int lastSlash = normalizedDir.LastIndexOf('/'); + if (lastSlash >= 0) + { + normalizedDir = normalizedDir.Substring(0, lastSlash); + } + + if ((normalizedDir + "/").StartsWith(normalizedAllowed, StringComparison.OrdinalIgnoreCase) + || (normalizedDir + "/").Equals(normalizedAllowed, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Normalizes a remote path by replacing backslashes with forward slashes + /// and collapsing "." and ".." segments to prevent traversal bypass. + /// + private static string NormalizeRemotePath(string path) + { + var normalizedPath = path.Replace('\\', '/'); + + // Collapse ".." and "." segments to prevent traversal bypass. + var segments = normalizedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + var stack = new List(); + foreach (var segment in segments) + { + if (segment == ".." && stack.Count > 0) + { + stack.RemoveAt(stack.Count - 1); + } + else if (segment != "." && segment != "..") + { + stack.Add(segment); + } + } + + return "/" + string.Join("/", stack); + } + + /// + /// Expands environment variables and resolves the path to its canonical form. + /// This must be called before validation to prevent validate/use mismatches. + /// + private static string CanonicalizePath(string path) + { + Ensure.NotNullOrWhitespace(path, nameof(path)); + + if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("//", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); } - string? directoryPath = Path.GetDirectoryName(path); + // Expand environment variables first, then canonicalize — so that + // validation and I/O operate on the same resolved path. + var expanded = Environment.ExpandEnvironmentVariables(path); + + // Re-check after expansion: an env var could have expanded to a UNC + // or extended-path prefix (e.g., %NETSHARE% → \\server\share). + if (expanded.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase) || + expanded.StartsWith("//", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); + } + + return Path.GetFullPath(expanded); + } + + /// + /// Checks whether a canonicalized file path falls within one of the allowed upload directories. + /// Subdirectories of allowed directories are also permitted. + /// + private bool IsUploadPathAllowed(string canonicalPath) + { + Ensure.NotNullOrWhitespace(canonicalPath, nameof(canonicalPath)); + + string? directoryPath = Path.GetDirectoryName(canonicalPath); if (string.IsNullOrEmpty(directoryPath)) { - throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(path)); + throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(canonicalPath)); } if (this._allowedUploadDirectories.Count == 0) @@ -170,8 +339,6 @@ private bool IsUploadPathAllowed(string path) return false; } - var canonicalDir = Path.GetFullPath(directoryPath); - foreach (var allowedDirectory in this._allowedUploadDirectories) { var canonicalAllowed = Path.GetFullPath(allowedDirectory); @@ -181,8 +348,8 @@ private bool IsUploadPathAllowed(string path) canonicalAllowed += separator; } - if (canonicalDir.StartsWith(canonicalAllowed, s_pathComparison) - || (canonicalDir + separator).Equals(canonicalAllowed, s_pathComparison)) + if (directoryPath.StartsWith(canonicalAllowed, s_pathComparison) + || (directoryPath + separator).Equals(canonicalAllowed, s_pathComparison)) { return true; } diff --git a/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs b/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs index 74971ff7d82a..262e7453944e 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs +++ b/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs @@ -114,7 +114,7 @@ public async Task UploadSmallFileAsync(string filePath, string destinationPath, } /// - public async Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "anonymous", + public async Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "organization", CancellationToken cancellationToken = default) { Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); diff --git a/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs b/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs index a54d46464ae1..e603ff68cca7 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs +++ b/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs @@ -19,7 +19,7 @@ public interface ICloudDriveConnector /// Scope of the link. /// The to monitor for cancellation requests. The default is . /// Shareable link. - Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "anonymous", CancellationToken cancellationToken = default); + Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "organization", CancellationToken cancellationToken = default); /// /// Get the content of a file. diff --git a/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs index a6e34f14dbe2..b8ce26d1bed4 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs @@ -25,10 +25,10 @@ public async Task UploadSmallFileAsyncSucceedsAsync() connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; // Act - await target.UploadFileAsync(anyFilePath, Guid.NewGuid().ToString()); + await target.UploadFileAsync(anyFilePath, "/remote.txt"); // Assert connectorMock.VerifyAll(); @@ -38,14 +38,14 @@ public async Task UploadSmallFileAsyncSucceedsAsync() public async Task CreateLinkAsyncSucceedsAsync() { // Arrange - string anyFilePath = Guid.NewGuid().ToString(); + string anyFilePath = "/Documents/report.docx"; string anyLink = Guid.NewGuid().ToString(); Mock connectorMock = new(); - connectorMock.Setup(c => c.CreateShareLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + connectorMock.Setup(c => c.CreateShareLinkAsync(anyFilePath, "view", "organization", It.IsAny())) .ReturnsAsync(anyLink); - CloudDrivePlugin target = new(connectorMock.Object); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents"] }; // Act string actual = await target.CreateLinkAsync(anyFilePath); @@ -55,19 +55,84 @@ public async Task CreateLinkAsyncSucceedsAsync() connectorMock.VerifyAll(); } + [Fact] + public async Task CreateLinkAsyncUsesOrganizationScopeAsync() + { + // Arrange + string anyFilePath = "/Documents/report.docx"; + string anyLink = Guid.NewGuid().ToString(); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.CreateShareLinkAsync(anyFilePath, "view", "organization", It.IsAny())) + .ReturnsAsync(anyLink); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents"] }; + + // Act + await target.CreateLinkAsync(anyFilePath); + + // Assert — verify "organization" scope was passed, not "anonymous" + connectorMock.Verify(c => c.CreateShareLinkAsync(anyFilePath, "view", "organization", It.IsAny()), Times.Once); + } + + [Fact] + public async Task CreateLinkAsyncDeniesAllPathsByDefaultAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object); + + // Act & Assert — default config denies all share paths + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Documents/secret.docx")); + } + + [Fact] + public async Task CreateLinkAsyncDeniesPathsOutsideAllowedAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents/Public"] }; + + // Act & Assert — path outside allowed share paths is denied + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Confidential/secret.docx")); + } + + [Fact] + public async Task CreateLinkAsyncAllowsSubdirectoriesOfAllowedSharePathsAsync() + { + // Arrange + string filePath = "/Documents/Public/Reports/Q1/summary.docx"; + string anyLink = Guid.NewGuid().ToString(); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.CreateShareLinkAsync(filePath, "view", "organization", It.IsAny())) + .ReturnsAsync(anyLink); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents/Public"] }; + + // Act — subdirectory of allowed share path should succeed + string actual = await target.CreateLinkAsync(filePath); + + // Assert + Assert.Equal(anyLink, actual); + connectorMock.VerifyAll(); + } + [Fact] public async Task GetFileContentAsyncSucceedsAsync() { - string anyFilePath = Guid.NewGuid().ToString(); + string anyFilePath = "/Documents/report.txt"; string expectedContent = Guid.NewGuid().ToString(); using MemoryStream expectedStream = new(Encoding.UTF8.GetBytes(expectedContent)); // Arrange Mock connectorMock = new(); - connectorMock.Setup(c => c.GetFileContentStreamAsync(It.IsAny(), It.IsAny())) + connectorMock.Setup(c => c.GetFileContentStreamAsync(anyFilePath, It.IsAny())) .ReturnsAsync(expectedStream); - CloudDrivePlugin target = new(connectorMock.Object); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents"] }; // Act string? actual = await target.GetFileContentAsync(anyFilePath); @@ -99,9 +164,7 @@ public async Task ItDeniesPathTraversalAsync() var traversalPath = Path.Combine(allowedDir, "..", "outside-folder", "secret.txt"); Mock connectorMock = new(); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; - - // Act & Assert — traversal path is canonicalized and rejected + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; await Assert.ThrowsAsync(async () => await target.UploadFileAsync(traversalPath, "/remote.txt")); } @@ -111,7 +174,7 @@ public async Task ItDeniesUncPathsAsync() { // Arrange Mock connectorMock = new(); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()] }; + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; // Act & Assert — UNC paths are rejected (ArgumentException on Windows, InvalidOperationException on Linux // where the path is canonicalized differently and fails the allowlist check instead) @@ -127,7 +190,7 @@ public async Task ItDeniesDisallowedDirectoriesAsync() var disallowedPath = Path.Combine(Path.GetTempPath(), "disallowed", "file.txt"); Mock connectorMock = new(); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; // Act & Assert await Assert.ThrowsAsync(async () => @@ -145,7 +208,7 @@ public async Task ItAllowsSubdirectoriesOfAllowedDirectoriesAsync() connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; // Act — subdirectory of allowed folder should succeed await target.UploadFileAsync(subDirPath, "/remote.txt"); @@ -170,7 +233,7 @@ public async Task ItExpandsEnvironmentVariablesAndValidatesAsync() connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [tempDir] }; + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [tempDir], AllowedUploadDestinationPaths = ["/"] }; // Act — env var should be expanded and path should be allowed await target.UploadFileAsync(envVarPath, "/remote.txt"); @@ -198,7 +261,7 @@ public async Task ItDeniesExpandedEnvironmentVariablePathsOutsideAllowedAsync() var envVarPath = Path.Combine($"%{envVarName}%", "outside-file.txt"); Mock connectorMock = new(); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; // Act & Assert — expanded path is outside allowed directory await Assert.ThrowsAsync(async () => @@ -221,7 +284,7 @@ public async Task ItRespectsPlatformCaseSensitivityAsync() connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -236,4 +299,282 @@ await Assert.ThrowsAsync(async () => await target.UploadFileAsync(filePath, "/remote.txt")); } } + + [Fact] + public async Task ItDeniesEnvVarExpansionToUncPathAsync() + { + // Arrange — env var that expands to a UNC path should be rejected + var envVarName = "SK_TEST_UNC_" + Guid.NewGuid().ToString("N")[..8]; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + + try + { + Environment.SetEnvironmentVariable(envVarName, "\\\\server\\share"); + var maliciousPath = Path.Combine($"%{envVarName}%", "secret.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — UNC path after env-var expansion should be rejected + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(maliciousPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task CreateLinkAsyncDeniesPathTraversalAsync() + { + // Arrange — path with ".." segments that would bypass the allowed path prefix + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents"] }; + + // Act & Assert — traversal path should be denied even though it string-starts-with /Documents + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Documents/../Confidential/secret.docx")); + } + + [Fact] + public async Task CreateLinkAsyncDeniesPathTraversalWithSubdirAsync() + { + // Arrange — traversal from an allowed subdirectory to an unauthorized location + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents/Public"] }; + + // Act & Assert — traversal should be denied + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Documents/Public/../Confidential/secret.docx")); + } + + [Fact] + public async Task ItDeniesForwardSlashUncPathsAsync() + { + // Arrange — forward-slash UNC paths should also be rejected + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — forward-slash UNC path is rejected + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync("//server/share/file.txt", "/remote.txt")); + } + + [Fact] + public async Task ItDeniesEnvVarExpansionToForwardSlashUncPathAsync() + { + // Arrange — env var that expands to a forward-slash UNC path should be rejected + var envVarName = "SK_TEST_FWDUNC_" + Guid.NewGuid().ToString("N")[..8]; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + + try + { + Environment.SetEnvironmentVariable(envVarName, "//server/share"); + var maliciousPath = $"%{envVarName}%/secret.txt"; + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — forward-slash UNC path after env-var expansion should be rejected + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(maliciousPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task ItDeniesEnvVarTraversalBypassAsync() + { + // Arrange — env var that expands to a traversal path + var allowedDir = Path.Combine(Path.GetTempPath(), "allowed-sandbox"); + var envVarName = "SK_TEST_TRAV_" + Guid.NewGuid().ToString("N")[..8]; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + + try + { + Environment.SetEnvironmentVariable(envVarName, + $"{Path.DirectorySeparatorChar}..{Path.DirectorySeparatorChar}elsewhere"); + var maliciousPath = Path.Combine(allowedDir, $"%{envVarName}%", "secret.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — after env-var expansion, the canonical path lands outside the allowed directory + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(maliciousPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task GetFileContentAsyncDeniesAllPathsByDefaultAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object); + + // Act & Assert — default config denies all read paths + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Documents/secret.docx")); + } + + [Fact] + public async Task GetFileContentAsyncDeniesPathsOutsideAllowedAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents/Public"] }; + + // Act & Assert — path outside allowed read paths is denied + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Confidential/secret.docx")); + } + + [Fact] + public async Task GetFileContentAsyncDeniesPathTraversalAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents"] }; + + // Act & Assert — traversal path should be denied + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Documents/../Confidential/secret.docx")); + } + + [Fact] + public async Task GetFileContentAsyncAllowsSubdirectoriesOfAllowedReadPathsAsync() + { + // Arrange + string filePath = "/Documents/Public/Reports/Q1/summary.docx"; + string expectedContent = "file content"; + using MemoryStream expectedStream = new(Encoding.UTF8.GetBytes(expectedContent)); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.GetFileContentStreamAsync(filePath, It.IsAny())) + .ReturnsAsync(expectedStream); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents/Public"] }; + + // Act + string? actual = await target.GetFileContentAsync(filePath); + + // Assert + Assert.Equal(expectedContent, actual); + connectorMock.VerifyAll(); + } + + [Fact] + public async Task GetFileContentAsyncDoesNotCallConnectorWhenDeniedAsync() + { + // Arrange + Mock connectorMock = new(MockBehavior.Strict); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents"] }; + + // Act & Assert — connector should never be called for denied paths + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Confidential/secret.docx")); + connectorMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task UploadFileAsyncDeniesDisallowedDestinationPathAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [allowedDir], + AllowedUploadDestinationPaths = ["/Documents/Uploads"] + }; + + // Act & Assert — destination outside allowed paths is denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(anyFilePath, "/Confidential/secret.docx")); + } + + [Fact] + public async Task UploadFileAsyncDeniesAllDestinationPathsByDefaultAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + + // Act & Assert — default config denies all destination paths + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(anyFilePath, "/remote.txt")); + } + + [Fact] + public async Task UploadFileAsyncAllowsDestinationInAllowedPathAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [allowedDir], + AllowedUploadDestinationPaths = ["/Documents"] + }; + + // Act + await target.UploadFileAsync(anyFilePath, "/Documents/Uploads/report.txt"); + + // Assert + connectorMock.VerifyAll(); + } + + [Fact] + public async Task UploadFileAsyncDeniesDestinationPathTraversalAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [allowedDir], + AllowedUploadDestinationPaths = ["/Documents"] + }; + + // Act & Assert — traversal in destination path should be denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(anyFilePath, "/Documents/../Confidential/secret.docx")); + } + + [Fact] + public async Task UploadFileAsyncDoesNotCallConnectorWhenDestinationDeniedAsync() + { + // Arrange + Mock connectorMock = new(MockBehavior.Strict); + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [Path.GetTempPath()], + AllowedUploadDestinationPaths = ["/Documents"] + }; + + // Act & Assert — connector should never be called when destination is denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(Path.Combine(Path.GetTempPath(), "file.txt"), "/Confidential/secret.docx")); + connectorMock.VerifyNoOtherCalls(); + } } From f2b3c931d1bc43b5630eb16aea24a4fe93d56699 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 8 May 2026 20:14:20 +0100 Subject: [PATCH 25/32] .Net: Version bump 1.76.0 (#13972) ### Motivation and Context Bump SK .NET version for the next release. ### Description - VersionPrefix: 1.75.0 1.76.0 - PackageValidationBaselineVersion: 1.74.0 1.75.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/nuget/nuget-package.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 8dc75729a794..bb49ccbb2a4f 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,7 +1,7 @@ - 1.75.0 + 1.76.0 $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix) @@ -9,7 +9,7 @@ true - 1.74.0 + 1.75.0 $(NoWarn);CP0003 From 590003243af26f83cefa8a3768688065a8b5484e Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 11 May 2026 17:37:38 +0100 Subject: [PATCH 26/32] .Net: Pin SharpCompress 0.48.0 to fix GHSA-6c8g-7p36-r338 (#13977) ## Summary Fixes a moderate-severity transitive vulnerability flagged by `dotnet list package --vulnerable --include-transitive` across 10 projects in the solution. | Package | Current (resolved) | Pinned | Severity | Advisory | |---|---|---|---|---| | SharpCompress | 0.30.1 | 0.48.0 | Moderate | [GHSA-6c8g-7p36-r338](https://github.com/advisories/GHSA-6c8g-7p36-r338) | ## Why a transitive pin `SharpCompress` is pulled in transitively via `MongoDB.Driver 3.5.2`: `MongoDB.Driver (v3.5.2) -> SharpCompress (v0.30.1)` The latest `MongoDB.Driver` (3.8.0) still declares `SharpCompress (>= 0.30.1)` as a floor, so a minor bump of `MongoDB.Driver` does not lift the resolved `SharpCompress` version. There is no transitive update path through the direct dependency. The advisory's vulnerable range is `<= 0.47.4`, so `0.48.0` is the smallest non-vulnerable release. `1.0.0` was rejected as it is a major-version jump. ## Changes This mirrors the existing `Snappier` vulnerability pin pattern already in the repo (same file locations, same comment style): 1. `dotnet/Directory.Packages.props` -- add `PackageVersion` for `SharpCompress 0.48.0` next to the `MongoDB.Driver` / `Snappier` entries with a vulnerability comment. 2. `dotnet/src/VectorData/MongoDB/MongoDB.csproj` -- add direct `PackageReference Include="SharpCompress"` to override the transitive resolution. 3. `dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj` -- same. The pin in the two `Connectors.*` source projects propagates the override to all downstream consumers (samples, integration tests, conformance tests, unit tests). ## Verification | Check | Scope | Result | |---|---|---| | `dotnet list SK-dotnet.slnx package --vulnerable --include-transitive` | Full solution (155 projects) | All clean (was 10 projects flagging) | | `dotnet build --warnaserror` | `MongoDB.csproj` | Pass | | `dotnet build --warnaserror` | `CosmosMongoDB.csproj` | Pass | | `dotnet format --verify-no-changes` (CI-parity Docker `mcr.microsoft.com/dotnet/sdk:10.0`) | Both csproj | Pass (Formatted 0 of N files) | | `dotnet test` | `MongoDB.UnitTests` | 37/37 passed | | `dotnet test` | `CosmosMongoDB.UnitTests` | 28/28 passed | Conformance test projects skipped (require live emulators, pre-existing env-dependent suites, not impacted by a transitive pin). --- dotnet/Directory.Packages.props | 1 + dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj | 1 + dotnet/src/VectorData/MongoDB/MongoDB.csproj | 1 + 3 files changed, 3 insertions(+) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1947e6bede0c..03f2b67390f5 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -172,6 +172,7 @@ + diff --git a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj index 6cfc45fc2881..0d6bca948aaf 100644 --- a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj +++ b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj @@ -30,6 +30,7 @@ + diff --git a/dotnet/src/VectorData/MongoDB/MongoDB.csproj b/dotnet/src/VectorData/MongoDB/MongoDB.csproj index d3200c604133..4bc2786c1161 100644 --- a/dotnet/src/VectorData/MongoDB/MongoDB.csproj +++ b/dotnet/src/VectorData/MongoDB/MongoDB.csproj @@ -30,6 +30,7 @@ + From 3f0e2d58fa687f598cbc18b7314f54f9e4171361 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 11 May 2026 23:48:41 +0100 Subject: [PATCH 27/32] Python: Percent-encode OpenAPI path params & pin azure-search-documents (#13967) Apply `urllib.parse.quote(safe='')` to path parameter values before substitution in `build_path()`, consistent with the .NET implementation which uses `HttpUtility.UrlEncode`. ### Changes - `rest_api_operation.py`: encode path parameter values per RFC 3986 - `test_sk_openapi.py`: add tests for encoded path parameters - `pyproject.toml` / `uv.lock`: pin `azure-search-documents >= 11.6.0b4, < 12.0.0` ### Pin azure-search-documents < 12.0.0 Version 12.0.0 removed the internal `_endpoint` attribute from `SearchIndexClient`, which breaks `AzureAISearchCollection` initialization at `azure_ai_search.py:158`. Pinning to `< 12.0.0` until the code is updated to use the new public API. ### Tests All existing and new `build_path` tests pass. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/pyproject.toml | 6 ++- .../models/rest_api_operation.py | 4 +- .../openapi_plugin/test_sk_openapi.py | 50 +++++++++++++++++++ python/uv.lock | 4 +- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index c3044451cb76..3383b6db5f1d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -73,7 +73,7 @@ aws = [ azure = [ "azure-ai-inference >= 1.0.0b6", "azure-core-tracing-opentelemetry >= 1.0.0b11", - "azure-search-documents >= 11.6.0b4", + "azure-search-documents >= 11.6.0b4, < 12.0.0", "azure-cosmos ~= 4.7" ] chroma = [ @@ -118,7 +118,9 @@ ollama = [ onnx = [ # onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10. "onnxruntime==1.22.1; python_version == '3.10'", - "onnxruntime>=1.24.3; python_version > '3.10'", + # onnxruntime 1.26.0 has no macOS ARM64 wheel; pin to last compatible version. + # https://github.com/microsoft/onnxruntime/issues/28441 + "onnxruntime>=1.24.3, <1.26.0; python_version > '3.10'", "onnxruntime-genai==0.9.0" ] oracledb = [ diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py index 7963c55883e8..570a4352892c 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py @@ -2,7 +2,7 @@ import re from typing import Any, Final -from urllib.parse import ParseResult, ParseResultBytes, urlencode, urljoin, urlparse, urlunparse +from urllib.parse import ParseResult, ParseResultBytes, quote, urlencode, urljoin, urlparse, urlunparse from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( RestApiExpectedResponse, @@ -288,7 +288,7 @@ def build_path(self, path_template: str, arguments: dict[str, Any]) -> str: f"required parameter of the operation - `{self.id}`." ) continue - path_template = path_template.replace(f"{{{parameter.name}}}", str(argument)) + path_template = path_template.replace(f"{{{parameter.name}}}", quote(str(argument), safe="")) return path_template def build_query_string(self, arguments: dict[str, Any]) -> str: diff --git a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py index 1d25486b5a86..2dd2488fc506 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py +++ b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py @@ -412,6 +412,56 @@ def test_build_path_with_optional_and_required_parameters(): assert operation.build_path(operation.path, arguments) == expected_path +def test_build_path_encodes_special_characters(): + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + # Characters like /, ?, #, and spaces must be percent-encoded to prevent traversal + arguments = {"id": "foo/bar?q=1#frag data"} + result = operation.build_path(operation.path, arguments) + encoded_part = result.split("/resource/")[1] + assert "/" not in encoded_part + assert "?" not in encoded_part + assert "#" not in encoded_part + assert " " not in encoded_part + # Python's quote(safe="") encodes all except unreserved chars (letters, digits, _, ., -, ~) + assert result == "/resource/foo%2Fbar%3Fq%3D1%23frag%20data" + + +def test_build_path_prevents_path_traversal(): + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + arguments = {"id": "../../admin"} + result = operation.build_path(operation.path, arguments) + # The slashes must be encoded so ../../admin becomes a single path segment, not a traversal + assert result == "/resource/..%2F..%2Fadmin" + + +def test_build_path_double_encodes_pre_encoded_values(): + """Arguments must be raw/unencoded values. Pre-encoded values are double-encoded by design.""" + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + arguments = {"id": "hello%2Fworld"} + result = operation.build_path(operation.path, arguments) + # %2F in input becomes %252F — the % is encoded, preventing decode-based bypass + assert result == "/resource/hello%252Fworld" + + +def test_build_path_encodes_unicode_characters(): + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + arguments = {"id": "café résumé"} + result = operation.build_path(operation.path, arguments) + assert result == "/resource/caf%C3%A9%20r%C3%A9sum%C3%A9" + + def test_build_query_string_with_required_parameter(): parameters = [ RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True) diff --git a/python/uv.lock b/python/uv.lock index 430794738a60..d1093258d222 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -6480,7 +6480,7 @@ requires-dist = [ { name = "azure-core-tracing-opentelemetry", marker = "extra == 'azure'", specifier = ">=1.0.0b11" }, { name = "azure-cosmos", marker = "extra == 'azure'", specifier = "~=4.7" }, { name = "azure-identity", specifier = ">=1.13" }, - { name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4" }, + { name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4,<12.0.0" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.36.4,<1.41.0" }, { name = "chromadb", marker = "extra == 'chroma'", specifier = ">=0.5,<1.4" }, { name = "cloudevents", specifier = "~=1.0" }, @@ -6502,7 +6502,7 @@ requires-dist = [ { name = "numpy", marker = "python_full_version >= '3.12'", specifier = ">=1.26.0" }, { name = "ollama", marker = "extra == 'ollama'", specifier = "~=0.4" }, { name = "onnxruntime", marker = "python_full_version == '3.10.*' and extra == 'onnx'", specifier = "==1.22.1" }, - { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3" }, + { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3,<1.26.0" }, { name = "onnxruntime-genai", marker = "extra == 'onnx'", specifier = "==0.9.0" }, { name = "openai", specifier = ">=2.0.0" }, { name = "openapi-core", specifier = ">=0.18,<0.20" }, From fdc6e68f5892c3f76519d22a770d7e0a12a261f6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 12 May 2026 15:49:47 +0100 Subject: [PATCH 28/32] .Net: Semantic Kernel -> Agent Framework - Migration/Samples Update (AF 1.0 Compatible) (#13852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Updates the SK → Agent Framework migration document, samples, and bridge code from AF preview (`1.0.0-preview.251009.1`) to GA (`1.0.0`). ### Breaking changes addressed | Preview API | AF 1.0 GA | Scope | |---|---|---| | `AgentThread` | `AgentSession` | Core type rename | | `GetNewThread()` | `CreateSessionAsync()` | Now async | | `AgentRunResponse` | `AgentResponse` | Return type rename | | `AgentRunResponseUpdate` | `AgentResponseUpdate` | Streaming type rename | | `CreateAIAgent()` | `AsAIAgent()` | Extension method rename | | `AssistantClient.CreateAIAgentAsync()` | **Removed** | Redirect to Responses API | | `PersistentAgentsClient` | **Obsolete** | Use `AIProjectClient` via `Microsoft.Agents.AI.Foundry` | | `Microsoft.Agents.AI.AzureAI` | `Microsoft.Agents.AI.Foundry` | Package rename | | `InProcessExecution.StreamAsync` | `RunStreamingAsync` | Workflow API | | `AgentRunUpdateEvent` | `AgentResponseUpdateEvent` | Workflow event | | `AIAgent.Id` (virtual) | `IdCore` (protected virtual) | Override pattern | ### Additional discoveries - `using OpenAI.Chat;` required for `ChatClient.AsAIAgent()` extension to resolve - `using OpenAI.Responses;` required for `ResponsesClient.AsAIAgent()` extension to resolve ### What changed **Package versions** (`dotnet/Directory.Packages.props`) - AF packages updated to `1.0.0` - `Microsoft.Agents.AI.AzureAI` → `Microsoft.Agents.AI.Foundry` **Bridge code** (`dotnet/src/Agents/Abstractions/AIAgent/`) - `SemanticKernelAIAgent.cs`: New `Core` method overrides (`CreateSessionCoreAsync`, `RunCoreAsync`, etc.) - `SemanticKernelAIAgentThread` → `SemanticKernelAIAgentSession` (extends `AgentSession`) **31 migration samples** (all compile ✅) - OpenAI/AzureOpenAI: `AsAIAgent`, `CreateSessionAsync`, `using OpenAI.Chat;` - Assistants (8): AF sections rewritten to Responses API - Responses (8): Direct `ResponsesClient.AsAIAgent()` - Foundry (4): `AIProjectClient.AsAIAgent()` via `Microsoft.Agents.AI.Foundry` - Orchestrations (3): `RunStreamingAsync`, `AgentResponseUpdateEvent` **Migration document** (`.github/upgrades/prompts/SemanticKernelToAgentFramework.md`) - All AF preview API references updated to 1.0 GA patterns **Unit/integration tests** (~15 files) - Mechanical renames for `AgentSession`, `AgentResponse`, `CreateSessionAsync` ### Validation 8 standalone console programs were created and executed against AF 1.0.0 NuGet packages with real API keys, validating all migration patterns end-to-end: 1. OpenAI Basics (ChatClient → AsAIAgent, CreateSessionAsync, Run/RunStreaming) 2. OpenAI ToolCall (AIFunctionFactory.Create, tool invocation) 3. OpenAI DI (ServiceProvider registration) 4. OpenAI Responses (ResponsesClient.AsAIAgent) 5. Azure AI Foundry (AIProjectClient.AsAIAgent) 6. Workflows Handoff (HandoffWorkflowBuilder, InProcessExecution.RunStreamingAsync) 7. Session Lifecycle (Create, Serialize, Deserialize, multi-turn) 8. Assistants Migration Path (Responses API as replacement) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prompts/SemanticKernelToAgentFramework.md | 160 +++++++------- dotnet/Directory.Packages.props | 10 +- ...entOrchestrations_Step01_Concurrent.csproj | 4 +- .../Step01_Concurrent/Program.cs | 4 +- ...entOrchestrations_Step02_Sequential.csproj | 4 +- .../Step02_Sequential/Program.cs | 4 +- .../AgentOrchestrations_Step03_Handoff.csproj | 4 +- .../Step03_Handoff/Program.cs | 6 +- .../AzureAIFoundry_Step01_Basics.csproj | 2 +- .../AzureAIFoundry/Step01_Basics/Program.cs | 29 ++- .../AzureAIFoundry_Step02_ToolCall.csproj | 2 +- .../AzureAIFoundry/Step02_ToolCall/Program.cs | 29 ++- ...IFoundry_Step03_DependencyInjection.csproj | 2 +- .../Step03_DependencyInjection/Program.cs | 34 ++- ...ureAIFoundry_Step04_CodeInterpreter.csproj | 4 +- .../Step04_CodeInterpreter/Program.cs | 67 ++---- .../AzureOpenAI/Step01_Basics/Program.cs | 8 +- .../AzureOpenAI/Step02_ToolCall/Program.cs | 4 +- .../Step03_DependencyInjection/Program.cs | 4 +- .../Step04_ToolCall_WithOpenAPI/Program.cs | 4 +- ...AzureOpenAIAssistants_Step01_Basics.csproj | 23 -- .../Step01_Basics/Program.cs | 120 ----------- ...ureOpenAIAssistants_Step02_ToolCall.csproj | 23 -- .../Step02_ToolCall/Program.cs | 147 ------------- ...sistants_Step03_DependencyInjection.csproj | 23 -- .../Step03_DependencyInjection/Program.cs | 144 ------------- ...AIAssistants_Step04_CodeInterpreter.csproj | 23 -- .../Step04_CodeInterpreter/Program.cs | 196 ------------------ .../Step01_Basics/Program.cs | 14 +- .../Step02_ReasoningModel/Program.cs | 14 +- .../Step03_ToolCall/Program.cs | 6 +- .../Step04_DependencyInjection/Program.cs | 6 +- .../OpenAI/Step01_Basics/Program.cs | 7 +- .../OpenAI/Step02_ToolCall/Program.cs | 5 +- .../Step03_DependencyInjection/Program.cs | 3 +- .../OpenAIAssistants_Step01_Basics.csproj | 23 -- .../OpenAIAssistants/Step01_Basics/Program.cs | 116 ----------- .../OpenAIAssistants_Step02_ToolCall.csproj | 23 -- .../Step02_ToolCall/Program.cs | 145 ------------- ...sistants_Step03_DependencyInjection.csproj | 23 -- .../Step03_DependencyInjection/Program.cs | 142 ------------- ...AIAssistants_Step04_CodeInterpreter.csproj | 23 -- .../Step04_CodeInterpreter/Program.cs | 192 ----------------- .../OpenAIResponses/Step01_Basics/Program.cs | 13 +- .../Step02_ReasoningModel/Program.cs | 13 +- .../Step03_ToolCall/Program.cs | 4 +- .../Step04_DependencyInjection/Program.cs | 5 +- .../AIAgent/SemanticKernelAIAgent.cs | 49 +++-- ...ead.cs => SemanticKernelAIAgentSession.cs} | 14 +- .../UnitTests/A2A/A2AAgentExtensionsTests.cs | 32 +-- ...s => SemanticKernelAIAgentSessionTests.cs} | 105 ++-------- .../AIAgent/SemanticKernelAIAgentTests.cs | 40 ++-- .../Agents/UnitTests/AgentExtensionsTests.cs | 18 +- .../AzureAI/AzureAIAgentExtensionsTests.cs | 32 +-- .../BedrockAgentExtensionsTests.cs | 30 +-- .../CopilotStudioAgentExtensionsTests.cs | 32 +-- .../ChatCompletionAgentExtensionsTests.cs | 32 +-- .../OpenAIAssistantAgentExtensionsTests.cs | 32 +-- .../OpenAIResponseAgentExtensionsTests.cs | 62 +++--- .../KernelCore/KernelTests.cs | 44 ++-- .../KernelCore/KernelTests.cs | 44 ++-- .../SemanticKernelAIAgentTests.cs | 10 +- .../AI/PromptExecutionSettingsExtensions.cs | 20 +- 63 files changed, 497 insertions(+), 1960 deletions(-) delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/Program.cs delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/Program.cs delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj delete mode 100644 dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs rename dotnet/src/Agents/Abstractions/AIAgent/{SemanticKernelAIAgentThread.cs => SemanticKernelAIAgentSession.cs} (56%) rename dotnet/src/Agents/UnitTests/AIAgent/{SemanticKernelAIAgentThreadTests.cs => SemanticKernelAIAgentSessionTests.cs} (51%) diff --git a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md index cd5a54de1d49..6644b3b1e001 100644 --- a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md +++ b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md @@ -1,4 +1,4 @@ -# Instructions for migrating from Semantic Kernel Agents to Agent Framework in .NET projects. +# Instructions for migrating from Semantic Kernel Agents to Agent Framework in .NET projects. ## Scope @@ -22,13 +22,13 @@ For each project that needs to be migrated, you need to do the following: solution or you could not find the project, notify user and continue with other projects). - Identify the specific Semantic Kernel agent types being used: - `ChatCompletionAgent` → `ChatClientAgent` - - `OpenAIAssistantAgent` → `assistantsClient.CreateAIAgent()` (via OpenAI Assistants client extension) - - `AzureAIAgent` → `persistentAgentsClient.CreateAIAgent()` (via Azure AI Foundry client extension) - - `OpenAIResponseAgent` → `responsesClient.CreateAIAgent()` (via OpenAI Responses client extension) + - `OpenAIAssistantAgent` → `assistantsClient.AsAIAgent()` (via OpenAI Assistants client extension) + - `AzureAIAgent` → `persistentAgentsClient.AsAIAgent()` (via Azure AI Foundry client extension) + - `OpenAIResponseAgent` → `responsesClient.AsAIAgent()` (via OpenAI Responses client extension) - `A2AAgent` → `AIAgent` (via A2A card resolver) - `BedrockAgent` → Custom implementation required (not supported) - Determine if agents are being created new or retrieved from hosted services: - - **New agents**: Use `CreateAIAgent()` methods + - **New agents**: Use `AsAIAgent()` methods - **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Azure AI Foundry @@ -105,8 +105,8 @@ After completing migration, verify these specific items: 1. **Compilation**: Execute `dotnet build` on all modified projects - zero errors required 2. **Namespace Updates**: Confirm all `using Microsoft.SemanticKernel.Agents` statements are replaced 3. **Method Calls**: Verify all `InvokeAsync` calls are changed to `RunAsync` -4. **Return Types**: Confirm handling of `AgentRunResponse` instead of `IAsyncEnumerable>` -5. **Thread Creation**: Validate all thread creation uses `agent.GetNewThread()` pattern +4. **Return Types**: Confirm handling of `AgentResponse` instead of `IAsyncEnumerable>` +5. **Thread Creation**: Validate all thread creation uses `agent.CreateSessionAsync()` pattern 6. **Tool Registration**: Ensure `[KernelFunction]` attributes are removed and `AIFunctionFactory.Create()` is used 7. **Options Configuration**: Verify `AgentRunOptions` or `ChatClientAgentRunOptions` replaces `AgentInvokeOptions` 8. **Breaking Glass**: Test `RawRepresentation` access replaces `InnerContent` access @@ -119,8 +119,8 @@ Agent Framework provides functionality for creating and managing AI agents throu Key API differences: - Agent creation: Remove Kernel dependency, use direct client-based creation - Method names: `InvokeAsync` → `RunAsync`, `InvokeStreamingAsync` → `RunStreamingAsync` -- Return types: `IAsyncEnumerable>` → `AgentRunResponse` -- Thread creation: Provider-specific constructors → `agent.GetNewThread()` +- Return types: `IAsyncEnumerable>` → `AgentResponse` +- Thread creation: Provider-specific constructors → `agent.CreateSessionAsync()` - Tool registration: `KernelPlugin` system → Direct `AIFunction` registration - Options: `AgentInvokeOptions` → Provider-specific run options (e.g., `ChatClientAgentRunOptions`) @@ -142,14 +142,14 @@ Replace these Semantic Kernel agent classes with their Agent Framework equivalen |----------------------|----------------------------|-------------------| | `IChatCompletionService` | `IChatClient` | Convert to `IChatClient` using `chatService.AsChatClient()` extensions | | `ChatCompletionAgent` | `ChatClientAgent` | Remove `Kernel` parameter, add `IChatClient` parameter | -| `OpenAIAssistantAgent` | `AIAgent` (via extension) | **New**: `OpenAIClient.GetAssistantClient().CreateAIAgent()`
**Existing**: `OpenAIClient.GetAssistantClient().GetAIAgent(assistantId)` | -| `AzureAIAgent` | `AIAgent` (via extension) | **New**: `PersistentAgentsClient.CreateAIAgent()`
**Existing**: `PersistentAgentsClient.GetAIAgent(agentId)` | -| `OpenAIResponseAgent` | `AIAgent` (via extension) | Replace with `OpenAIClient.GetOpenAIResponseClient().CreateAIAgent()` | +| `OpenAIAssistantAgent` | `AIAgent` (via extension) | **New**: `OpenAIClient.GetAssistantClient().AsAIAgent()`
**Existing**: `OpenAIClient.GetAssistantClient().GetAIAgent(assistantId)` | +| `AzureAIAgent` | `AIAgent` (via extension) | **New**: `PersistentAgentsClient.AsAIAgent()`
**Existing**: `PersistentAgentsClient.GetAIAgent(agentId)` | +| `OpenAIResponseAgent` | `AIAgent` (via extension) | Replace with `OpenAIClient.GetResponsesClient().AsAIAgent()` | | `A2AAgent` | `AIAgent` (via extension) | Replace with `A2ACardResolver.GetAIAgentAsync()` | | `BedrockAgent` | Not supported | Custom implementation required | **Important distinction:** -- **CreateAIAgent()**: Use when creating new agents in the hosted service +- **AsAIAgent()**: Use when creating new agents in the hosted service - **GetAIAgent(agentId)**: Use when retrieving existing agents from the hosted service @@ -158,16 +158,16 @@ Replace these method calls: | Semantic Kernel Method | Agent Framework Method | Parameter Changes | |----------------------|----------------------|------------------| -| `agent.InvokeAsync(message, thread, options)` | `agent.RunAsync(message, thread, options)` | Same parameters, different return type | -| `agent.InvokeStreamingAsync(message, thread, options)` | `agent.RunStreamingAsync(message, thread, options)` | Same parameters, different return type | -| `new ChatHistoryAgentThread()` | `agent.GetNewThread()` | No parameters needed | -| `new OpenAIAssistantAgentThread(client)` | `agent.GetNewThread()` | No parameters needed | -| `new AzureAIAgentThread(client)` | `agent.GetNewThread()` | No parameters needed | +| `agent.InvokeAsync(message, thread, options)` | `agent.RunAsync(message, session, options)` | Same parameters, different return type | +| `agent.InvokeStreamingAsync(message, thread, options)` | `agent.RunStreamingAsync(message, session, options)` | Same parameters, different return type | +| `new ChatHistoryAgentThread()` | `await agent.CreateSessionAsync()` | Returns `AgentSession` | +| `new OpenAIAssistantAgentThread(client)` | `await agent.CreateSessionAsync()` | Returns `AgentSession` | +| `new AzureAIAgentThread(client)` | `await agent.CreateSessionAsync()` | Returns `AgentSession` | | `thread.DeleteAsync()` | Provider-specific cleanup | Use provider client directly | Return type changes: -- `IAsyncEnumerable>` → `AgentRunResponse` -- `IAsyncEnumerable` → `IAsyncEnumerable` +- `IAsyncEnumerable>` → `AgentResponse` +- `IAsyncEnumerable` → `IAsyncEnumerable` @@ -191,8 +191,8 @@ Agent Framework changes these behaviors compared to Semantic Kernel Agents: 1. **Thread Management**: Agent Framework automatically manages thread state. Semantic Kernel required manual thread updates in some scenarios (e.g., OpenAI Responses). 2. **Return Types**: - - Non-streaming: Returns single `AgentRunResponse` instead of `IAsyncEnumerable>` - - Streaming: Returns `IAsyncEnumerable` instead of `IAsyncEnumerable` + - Non-streaming: Returns single `AgentResponse` instead of `IAsyncEnumerable>` + - Streaming: Returns `IAsyncEnumerable` instead of `IAsyncEnumerable` 3. **Tool Registration**: Agent Framework uses direct function registration without requiring `[KernelFunction]` attributes. @@ -222,6 +222,8 @@ using Microsoft.Extensions.AI; using Microsoft.Agents.AI; // Provider-specific namespaces (add only if needed): using OpenAI; // For OpenAI provider +using OpenAI.Chat; // For ChatClient.AsAIAgent() extension +using OpenAI.Responses; // For ResponsesClient.AsAIAgent() extension using Azure.AI.OpenAI; // For Azure OpenAI provider using Azure.AI.Agents.Persistent; // For Azure AI Foundry provider using Azure.Identity; // For Azure authentication @@ -254,7 +256,7 @@ AIAgent agent = new ChatClientAgent(chatClient, instructions: "You are a helpful // Method 2: Extension method (recommended) AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); ``` @@ -314,7 +316,7 @@ AIAgent agent = new ChatClientAgent(chatClient, instructions: "You are a helpful // Method 2: Extension method (recommended) AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); ``` **Required changes:** @@ -338,12 +340,12 @@ AgentThread thread = new AzureAIAgentThread(azureClient); **With this unified Agent Framework pattern:** ```csharp // Use this single pattern for all agent types: -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` **Required changes:** 1. Remove all `new [Provider]AgentThread()` constructor calls -2. Replace with `agent.GetNewThread()` method call +2. Replace with `agent.CreateSessionAsync()` method call 3. Remove provider client parameters from thread creation 4. Use the same pattern regardless of agent provider type @@ -369,7 +371,7 @@ ChatCompletionAgent agent = new() { Kernel = kernel }; [Description("Get the weather for a location")] // Keep Description attribute static string GetWeather(string location) => $"Weather in {location}"; -AIAgent agent = chatClient.CreateAIAgent( +AIAgent agent = chatClient.AsAIAgent( instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); ``` @@ -415,12 +417,12 @@ await foreach (var item in agent.InvokeAsync(userInput, thread)) { ... } static string GetWeather(string location) => $"Weather in {location}"; // Start with an existing agent -AIAgent existingAgent = chatClient.CreateAIAgent( +AIAgent existingAgent = chatClient.AsAIAgent( instructions: "You are a helpful assistant"); // Create an augmented agent with additional tools using builder middleware var augmentedAgent = existingAgent.AsBuilder() - .Use(async (chatMessages, agentThread, agentRunOptions, next, cancellationToken) => + .Use(async (chatMessages, agentSession, agentRunOptions, next, cancellationToken) => { if (agentRunOptions is ChatClientAgentRunOptions chatClientAgentRunOptions) { @@ -429,12 +431,12 @@ var augmentedAgent = existingAgent.AsBuilder() chatClientAgentRunOptions.ChatOptions.Tools.Add(AIFunctionFactory.Create(GetWeather)); } - return await next(chatMessages, agentThread, agentRunOptions, cancellationToken); + return await next(chatMessages, agentSession, agentRunOptions, cancellationToken); }) .Build(); // Use the augmented agent with the additional tools -AgentRunResponse result = await augmentedAgent.RunAsync(userInput, thread); +AgentResponse result = await augmentedAgent.RunAsync(userInput, session); ``` **Required changes:** @@ -464,7 +466,7 @@ await foreach (AgentResponseItem item in agent.InvokeAsync(u **With this Agent Framework non-streaming pattern:** ```csharp -AgentRunResponse result = await agent.RunAsync(userInput, thread, options); +AgentResponse result = await agent.RunAsync(userInput, session, options); Console.WriteLine(result); ``` @@ -478,7 +480,7 @@ await foreach (StreamingChatMessageContent update in agent.InvokeStreamingAsync( **With this Agent Framework streaming pattern:** ```csharp -await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(userInput, thread, options)) +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(userInput, session, options)) { Console.Write(update); } @@ -487,8 +489,8 @@ await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(userInpu **Required changes:** 1. Replace `agent.InvokeAsync()` with `agent.RunAsync()` 2. Replace `agent.InvokeStreamingAsync()` with `agent.RunStreamingAsync()` -3. Change return type handling from `IAsyncEnumerable>` to `AgentRunResponse` -4. Change streaming type from `StreamingChatMessageContent` to `AgentRunResponseUpdate` +3. Change return type handling from `IAsyncEnumerable>` to `AgentResponse` +4. Change streaming type from `StreamingChatMessageContent` to `AgentResponseUpdate` 5. Remove `await foreach` for non-streaming calls 6. Access message content directly from result object instead of iterating @@ -537,7 +539,7 @@ services.AddTransient(sp => new() services.AddTransient(sp => new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are helpful")); + .AsAIAgent(instructions: "You are helpful")); ``` **Required changes:** @@ -564,11 +566,11 @@ For every thread created if there's intent to cleanup, the caller should track a ```csharp // For OpenAI Assistants (when cleanup is needed): var assistantClient = new OpenAIClient(apiKey).GetAssistantClient(); -await assistantClient.DeleteThreadAsync(thread.ConversationId); +await assistantClient.DeleteThreadAsync(session.ConversationId); // For Azure AI Foundry (when cleanup is needed): var persistentClient = new PersistentAgentsClient(endpoint, credential); -await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId); +await persistentClient.Threads.DeleteThreadAsync(session.ConversationId); // No thread and agent cleanup is needed for non-hosted agent providers like // - Azure OpenAI Chat Completion @@ -580,7 +582,7 @@ await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId); **Required changes:** 1. Remove `thread.DeleteAsync()` calls 2. Use provider-specific client for cleanup when required -3. Access thread ID via `thread.ConversationId` property +3. Access thread ID via `session.ConversationId` property 4. Only implement cleanup for providers that require it (Assistants, Azure AI Foundry) @@ -593,14 +595,14 @@ Use these exact patterns for each provider: ```csharp AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: instructions); + .AsAIAgent(instructions: instructions); ``` **OpenAI Assistants (New):** ```csharp AIAgent agent = new OpenAIClient(apiKey) .GetAssistantClient() - .CreateAIAgent(modelId, instructions: instructions); + .AsAIAgent(modelId, instructions: instructions); ``` **OpenAI Assistants (Existing):** @@ -614,13 +616,13 @@ AIAgent agent = new OpenAIClient(apiKey) ```csharp AIAgent agent = new AzureOpenAIClient(endpoint, credential) .GetChatClient(deploymentName) - .CreateAIAgent(instructions: instructions); + .AsAIAgent(instructions: instructions); ``` **Azure AI Foundry (New):** ```csharp AIAgent agent = new PersistentAgentsClient(endpoint, credential) - .CreateAIAgent(model: deploymentName, instructions: instructions); + .AsAIAgent(model: deploymentName, instructions: instructions); ``` **Azure AI Foundry (Existing):** @@ -665,9 +667,9 @@ using OpenAI; AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are helpful"); + .AsAIAgent(instructions: "You are helpful"); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` @@ -691,7 +693,7 @@ kernel.Plugins.Add(plugin); static string GetWeather([Description("Location")] string location) => $"Weather in {location}"; -AIAgent agent = chatClient.CreateAIAgent( +AIAgent agent = chatClient.AsAIAgent( instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); ``` @@ -714,7 +716,7 @@ await foreach (var result in agent.InvokeAsync(input, thread, options)) ```csharp ChatClientAgentRunOptions options = new(new ChatOptions { MaxOutputTokens = 1000 }); -AgentRunResponse result = await agent.RunAsync(input, thread, options); +AgentResponse result = await agent.RunAsync(input, session, options); Console.WriteLine(result); // Access underlying content when needed: @@ -742,7 +744,7 @@ await foreach (var result in agent.InvokeAsync(input, thread, options)) **With this Agent Framework non-streaming usage pattern:** ```csharp -AgentRunResponse result = await agent.RunAsync(input, thread, options); +AgentResponse result = await agent.RunAsync(input, session, options); Console.WriteLine($"Tokens: {result.Usage.TotalTokenCount}"); ``` @@ -762,7 +764,7 @@ await foreach (StreamingChatMessageContent response in agent.InvokeStreamingAsyn **With this Agent Framework streaming usage pattern:** ```csharp -await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread, options)) +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(input, session, options)) { if (update.Contents.OfType().FirstOrDefault() is { } usageContent) { @@ -787,7 +789,7 @@ await foreach (var content in agent.InvokeAsync(userInput, thread)) **With this Agent Framework breaking glass pattern:** ```csharp -var agentRunResponse = await agent.RunAsync(userInput, thread); +var agentRunResponse = await agent.RunAsync(userInput, session); // If the agent uses a ChatClient the first breaking glass probably will be a Microsoft.Extensions.AI.ChatResponse ChatResponse? chatResponse = agentRunResponse.RawRepresentation as ChatResponse; @@ -829,7 +831,7 @@ await foreach (var content in agent.InvokeAsync(userInput, thread)) **With this Agent Framework CodeInterpreter pattern:** ```csharp -var result = await agent.RunAsync(userInput, thread); +var result = await agent.RunAsync(userInput, session); Console.WriteLine(result); // Extract chat response MEAI type via first level breaking glass @@ -919,7 +921,7 @@ var openAIResponse = chatCompletion.GetRawResponse(); **Issue: Thread Type Mismatches** - **Problem**: Provider-specific thread constructors not found -- **Solution**: Replace all thread constructors with `agent.GetNewThread()` +- **Solution**: Replace all thread constructors with `agent.CreateSessionAsync()` **Issue: Options Configuration** - **Problem**: `AgentInvokeOptions` type not found @@ -937,7 +939,7 @@ var openAIResponse = chatCompletion.GetRawResponse(); 2. **Update Namespaces**: Replace SK namespaces with AF namespaces 3. **Update Agent Creation**: Remove Kernel, use direct client creation 4. **Update Method Calls**: Replace `InvokeAsync` with `RunAsync` -5. **Update Thread Creation**: Replace provider-specific constructors with `GetNewThread()` +5. **Update Thread Creation**: Replace provider-specific constructors with `await agent.CreateSessionAsync()` 6. **Update Tool Registration**: Remove attributes, use `AIFunctionFactory.Create()` 7. **Update Options**: Replace `AgentInvokeOptions` with provider-specific options 8. **Test and Validate**: Compile and test all functionality @@ -988,9 +990,9 @@ using OpenAI; AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` ### 2. Azure OpenAI Chat Completion Migration @@ -1038,7 +1040,7 @@ using Azure.Identity; AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); ``` ### 3. OpenAI Assistants Migration @@ -1083,12 +1085,12 @@ using OpenAI; AIAgent agent = new OpenAIClient(apiKey) .GetAssistantClient() - .CreateAIAgent(modelId, instructions: "You are a helpful assistant"); + .AsAIAgent(modelId, instructions: "You are a helpful assistant"); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); // Cleanup when needed -await assistantClient.DeleteThreadAsync(thread.ConversationId); +await assistantClient.DeleteThreadAsync(session.ConversationId); ``` **Retrieving an existing assistant:** @@ -1100,7 +1102,7 @@ AIAgent agent = new OpenAIClient(apiKey) .GetAssistantClient() .GetAIAgent(assistantId); // Use existing assistant ID -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` @@ -1163,12 +1165,12 @@ using Azure.Identity; var client = new PersistentAgentsClient(endpoint, new AzureCliCredential()); // Create a new AIAgent using Agent Framework -AIAgent agent = client.CreateAIAgent( +AIAgent agent = client.AsAIAgent( model: deploymentName, instructions: "You are a helpful assistant", tools: [/* List of specialized Azure.AI.Agents.Persistent.ToolDefinition types */]); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` **Retrieving an existing agent:** @@ -1182,7 +1184,7 @@ var client = new PersistentAgentsClient(endpoint, new AzureCliCredential()); // Retrieve an existing AIAgent using its ID AIAgent agent = await client.GetAIAgentAsync(agentId); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` @@ -1269,20 +1271,21 @@ await foreach (AgentResponseItem responseItem in responseIte Agent Framework automatically manages the thread, so there's no need to manually update it. ```csharp -using Microsoft.Agents.AI.OpenAI; +using OpenAI.Chat; // For ChatClient.AsAIAgent() +using OpenAI.Responses; // For ResponsesClient.AsAIAgent() AIAgent agent = new OpenAIClient(apiKey) - .GetOpenAIResponseClient(modelId) - .CreateAIAgent( + .GetResponsesClient(modelId) + .AsAIAgent( name: "ResponseAgent", instructions: "Answer all queries in English and French.", tools: [/* AITools */]); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); -var result = await agent.RunAsync(userInput, thread); +var result = await agent.RunAsync(userInput, session); -// The thread will be automatically updated with the new response id from this point +// The session will be automatically updated with the new response id from this point ``` @@ -1336,21 +1339,22 @@ await foreach (AgentResponseItem responseItem in responseIte Agent Framework automatically manages the thread, so there's no need to manually update it. ```csharp -using Microsoft.Agents.AI.OpenAI; +using OpenAI.Chat; // For ChatClient.AsAIAgent() +using OpenAI.Responses; // For ResponsesClient.AsAIAgent() using Azure.AI.OpenAI; AIAgent agent = new AzureOpenAIClient(endpoint, new AzureCliCredential()) - .GetOpenAIResponseClient(modelId) - .CreateAIAgent( + .GetResponsesClient(modelId) + .AsAIAgent( name: "ResponseAgent", instructions: "Answer all queries in English and French.", tools: [/* AITools */]); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); -var result = await agent.RunAsync(userInput, thread); +var result = await agent.RunAsync(userInput, session); -// The thread will be automatically updated with the new response id from this point +// The session will be automatically updated with the new response id from this point ``` @@ -1513,7 +1517,7 @@ AITool[] tools = AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent( + .AsAIAgent( instructions: "You are a weather assistant", tools: tools); ``` @@ -1562,10 +1566,10 @@ var renderedTemplate = await new KernelPromptTemplateFactory() AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: renderedTemplate); + .AsAIAgent(instructions: renderedTemplate); // No template variables in invocation - use plain string -var result = await agent.RunAsync("What's the weather?", thread); +var result = await agent.RunAsync("What's the weather?"); Console.WriteLine(result); ``` diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 03f2b67390f5..10568b08f85f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -50,10 +50,10 @@ - - - - + + + + @@ -136,7 +136,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj index 64306d74be5a..6ba4a6f1efe4 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs index ac6c2764349f..e1bd615ff139 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs @@ -79,13 +79,13 @@ async Task AFConcurrentAgentWorkflow() var spanishAgent = GetAFTranslationAgent("Spanish", client); var concurrentAgentWorkflow = AgentWorkflowBuilder.BuildConcurrent([frenchAgent, spanishAgent]); - await using StreamingRun run = await InProcessExecution.StreamAsync(concurrentAgentWorkflow, "Hello, world!"); + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(concurrentAgentWorkflow, "Hello, world!"); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); string? lastExecutorId = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentRunUpdateEvent e) + if (evt is AgentResponseUpdateEvent e) { if (string.IsNullOrEmpty(e.Update.Text)) { diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj index 64306d74be5a..6ba4a6f1efe4 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs index e317ac0ff873..81507d67ff00 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs @@ -82,13 +82,13 @@ async Task AFSequentialAgentWorkflow() var sequentialAgentWorkflow = AgentWorkflowBuilder.BuildSequential( [frenchAgent, spanishAgent, englishAgent]); - await using StreamingRun run = await InProcessExecution.StreamAsync(sequentialAgentWorkflow, "Hello, world!"); + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(sequentialAgentWorkflow, "Hello, world!"); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); string? lastExecutorId = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentRunUpdateEvent e) + if (evt is AgentResponseUpdateEvent e) { if (string.IsNullOrEmpty(e.Update.Text)) { diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj index 64306d74be5a..6ba4a6f1efe4 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs index f66fefe535bf..9d29efbd7fad 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable MAAIW001 // Experimental: HandoffWorkflowBuilder + using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; @@ -207,13 +209,13 @@ async Task AFHandoffAgentWorkflow() Console.WriteLine($"User: {query}"); messages.Add(new(ChatRole.User, query)); - await using var run = await InProcessExecution.StreamAsync(handoffAgentWorkflow, messages); + await using var run = await InProcessExecution.RunStreamingAsync(handoffAgentWorkflow, messages); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); string? lastExecutorId = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentRunUpdateEvent e) + if (evt is AgentResponseUpdateEvent e) { if (string.IsNullOrEmpty(e.Update.Text) && e.Update.Contents.Count == 0) { diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj index ae6b2961bf06..b48fbf454754 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs index 58e15a23c117..b608400c3312 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.SemanticKernel; @@ -67,7 +68,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -80,9 +81,9 @@ async Task SKAgent_As_AFAgentAsync() } // Clean up - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -91,29 +92,23 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); + // AF 1.0: Use AIProjectClient.AsAIAgent() from Microsoft.Agents.AI.Foundry + var projectClient = new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential()); - var agent = await azureAgentClient.CreateAIAgentAsync( + var agent = projectClient.AsAIAgent( deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); + instructions: "You are good at telling jokes.", + name: "GenerateStory"); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj index 8928edd44064..a8f443dc5012 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs index 8742cbcf61e1..cc586560740c 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -85,7 +86,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { Tools = [AIFunctionFactory.Create(GetWeather)] }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -98,9 +99,9 @@ async Task SKAgent_As_AFAgentAsync() } // Clean up - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -109,26 +110,22 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); + var agent = new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential()) + .AsAIAgent(model: deploymentName, + instructions: "You are a helpful assistant", + tools: [AIFunctionFactory.Create(GetWeather)]); - var agent = await azureAgentClient.CreateAIAgentAsync(deploymentName, instructions: "Answer questions about the menu"); + var session = await agent.CreateSessionAsync(); + var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { Tools = [AIFunctionFactory.Create(GetWeather)] }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); + // No cleanup needed - non-hosted path doesn't create server-side resources. } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj index 7631ed8887f5..60aee8306b56 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs index 7b3d5a8245c7..12f82b250de6 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -90,7 +91,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var result = await agent.RunAsync(userInput, thread); Console.WriteLine(result); @@ -103,9 +104,9 @@ async Task SKAgent_As_AFAgentAsync() // Clean up var azureAgentClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -115,36 +116,29 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new PersistentAgentsClient(azureEndpoint, new AzureCliCredential())); + serviceCollection.AddSingleton((sp) => new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential())); serviceCollection.AddTransient((sp) => { - var azureAgentClient = sp.GetRequiredService(); - - return azureAgentClient.CreateAIAgent( - deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); + var client = sp.GetRequiredService(); + return client.AsAIAgent( + deploymentName, + instructions: "You are good at telling jokes.", + name: "GenerateStory"); }); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); - var result = await agent.RunAsync(userInput, thread); + var result = await agent.RunAsync(userInput, session); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread)) + await foreach (var update in agent.RunStreamingAsync(userInput, session)) { Console.Write(update); } - // Clean up - var azureAgentClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); + // No cleanup needed - non-hosted path doesn't create server-side resources. } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj index e56e323b72a4..a8f443dc5012 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj @@ -1,4 +1,4 @@ - + Exe @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs index 4ad372455740..11d0e2bd82e0 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs @@ -2,6 +2,7 @@ using System.Text; using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -76,7 +77,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var result = await agent.RunAsync(userInput, thread); Console.WriteLine(result); @@ -124,9 +125,9 @@ async Task SKAgent_As_AFAgentAsync() } // Clean up - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -135,59 +136,15 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); - var agent = await azureAgentClient.CreateAIAgentAsync(deploymentName, tools: [new CodeInterpreterToolDefinition()]); - var thread = agent.GetNewThread(); + // Note: Code interpreter via hosted APIs requires versioned Foundry Agents. + // This sample creates a basic agent without code interpreter capabilities. + var agent = new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential()) + .AsAIAgent(deploymentName, instructions: "You are a helpful assistant with code execution capabilities."); - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - // To capture the code interpreter input we need to break glass all the updates raw representations, to check for the RunStepDetailsUpdate type and - // get the CodeInterpreterInput property which contains the generated code. - // Note: Similar logic would needed for each individual update if used in the agent.RunStreamingAsync streaming API to aggregate or yield the generated code. - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } + var session = await agent.CreateSessionAsync(); - // Update the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } + var result = await agent.RunAsync(userInput, session); + Console.WriteLine(result); - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); + // No cleanup needed - non-hosted path doesn't create server-side resources. } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs index 76e190840b15..6c4889244d24 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs @@ -6,7 +6,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; @@ -65,7 +65,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -83,9 +83,9 @@ async Task AFAgent() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs index 394415c09f18..f68c2e405b07 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; @@ -73,7 +73,7 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgent() { var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); + .AsAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); Console.WriteLine("\n=== AF Agent Response ===\n"); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs index fb682301b515..164b2e832c4e 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; @@ -72,7 +72,7 @@ async Task AFAgent() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new AzureOpenAIClient(new(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs index 5898050f5d7a..a6b0840b5091 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs @@ -9,7 +9,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Plugins.OpenApi; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -59,7 +59,7 @@ async Task AFAgent() new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: tools); + .AsAIAgent(instructions: "You are a helpful assistant", tools: tools); // Run the agent with the OpenAPI function tools. Console.WriteLine(await agent.RunAsync("Please list the names, colors and descriptions of all the labels available in the microsoft/agent-framework repository on github.")); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs deleted file mode 100644 index b0b6ca06bfd6..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - var agent = await assistantClient.CreateAIAgentAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs deleted file mode 100644 index 9566dd7f8ddc..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent agent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - await foreach (var result in agent.InvokeAsync(userInput, thread)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - skAgent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - var agent = await assistantClient.CreateAIAgentAsync(deploymentName, - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj deleted file mode 100644 index 8696e583426e..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs deleted file mode 100644 index 548f02348032..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddKernel().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(deploymentName, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - // Create a thread for the agent conversation. - var assistantsClient = serviceProvider.GetRequiredService(); - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddKernel().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(deploymentName, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var skAgent = serviceProvider.GetRequiredService(); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddTransient((sp) => - { - var assistantClient = sp.GetRequiredService(); - - return assistantClient.CreateAIAgent(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs deleted file mode 100644 index 324de34731e9..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Create a python code file using the code interpreter tool with a code ready to determine the values in the Fibonacci sequence that are less then the value of 101"; - -var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - // Respond to user input - await foreach (var content in agent.InvokeAsync(userInput, thread)) - { - if (!string.IsNullOrWhiteSpace(content.Message.Content)) - { - bool isCode = content.Message.Metadata?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; - Console.WriteLine($"\n# {content.Message.Role}{(isCode ? "\n# Generated Code:\n" : ":")}{content.Message.Content}"); - } - - // Check for the citations - foreach (var item in content.Message.Items) - { - // Process each item in the message - if (item is AnnotationContent annotation) - { - if (annotation.Kind != AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = await assistantsClient.CreateAIAgentAsync(deploymentName, tools: [new HostedCodeInterpreterTool()]); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs index 5f3552b2beab..8637a035cf4a 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs @@ -5,7 +5,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -66,7 +66,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -84,17 +84,17 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs index dc864675fbf9..5941a4de0097 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs @@ -7,7 +7,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI; using Microsoft.SemanticKernel.ChatCompletion; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -134,7 +134,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -180,10 +180,10 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(name: "Thinker", instructions: "You are good at thinking hard before answering."); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, name: "Thinker", instructions: "You are good at thinking hard before answering."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -194,7 +194,7 @@ async Task AFAgentAsync() } }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); // Retrieve the thinking as a full text block requires flattening multiple TextReasoningContents from multiple messages content lists. string assistantThinking = string.Join("\n", result.Messages @@ -207,7 +207,7 @@ async Task AFAgentAsync() Console.WriteLine($"Assistant: \n{assistantText}\n---\n"); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { var thinkingContents = update.Contents .OfType() diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs index 04b25861c54d..613b04ed914c 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -66,8 +66,8 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgentAsync() { var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); Console.WriteLine("\n=== AF Agent Response ===\n"); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs index a7b612c0a277..dfaf6a6a712e 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -71,8 +71,8 @@ async Task AFAgentAsync() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs index 9734b8abb016..9e7189c7bcfe 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Connectors.OpenAI; using OpenAI; +using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; @@ -64,7 +65,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -83,9 +84,9 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new OpenAIClient(apiKey).GetChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs index 00c687ca568e..333d7cb14afd 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs @@ -6,6 +6,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using OpenAI; +using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; @@ -65,7 +66,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -86,7 +87,7 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgentAsync() { - var agent = new OpenAIClient(apiKey).GetChatClient(model).CreateAIAgent( + var agent = new OpenAIClient(apiKey).GetChatClient(model).AsAIAgent( instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs index 0d8890d9dc8b..49c614517fc1 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using OpenAI; +using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; @@ -70,7 +71,7 @@ async Task AFAgentAsync() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new OpenAIClient(apiKey) .GetChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/Program.cs deleted file mode 100644 index 42e8c9bce711..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/Program.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var assistantsClient = new AssistantClient(apiKey); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -// Example of Semantic Kernel Agent code converted as an Agent Framework Agent -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var assistantsClient = new AssistantClient(apiKey); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - var afAgent = agent.AsAIAgent(); - - var thread = afAgent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await afAgent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in afAgent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AssistantClient(apiKey); - - var agent = await assistantClient.CreateAIAgentAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/Program.cs deleted file mode 100644 index c50204a2f6b8..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AssistantClient(apiKey); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent agent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - await foreach (var result in agent.InvokeAsync(userInput, thread)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AssistantClient(apiKey); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - skAgent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AssistantClient(apiKey); - - var agent = await assistantClient.CreateAIAgentAsync(model, - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj deleted file mode 100644 index 8696e583426e..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs deleted file mode 100644 index 2a3497a7e5f0..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddKernel().AddOpenAIChatClient(model, apiKey); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(model, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - // Create a thread for the agent conversation. - var assistantsClient = serviceProvider.GetRequiredService(); - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddKernel().AddOpenAIChatClient(model, apiKey); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(model, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var skAgent = serviceProvider.GetRequiredService(); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddTransient((sp) => - { - var assistantClient = sp.GetRequiredService(); - - return assistantClient.CreateAIAgent(model, name: "Joker", instructions: "You are good at telling jokes."); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs deleted file mode 100644 index b8c0db023c9d..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Create a python code file using the code interpreter tool with a code ready to determine the values in the Fibonacci sequence that are less then the value of 101"; - -var assistantsClient = new AssistantClient(apiKey); - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - // Respond to user input - await foreach (var content in agent.InvokeAsync(userInput, thread)) - { - if (!string.IsNullOrWhiteSpace(content.Message.Content)) - { - bool isCode = content.Message.Metadata?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; - Console.WriteLine($"\n# {content.Message.Role}{(isCode ? "\n# Generated Code:\n" : ":")}{content.Message.Content}"); - } - - // Check for the citations - foreach (var item in content.Message.Items) - { - // Process each item in the message - if (item is AnnotationContent annotation) - { - if (annotation.Kind != AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = await assistantsClient.CreateAIAgentAsync(model, tools: [new HostedCodeInterpreterTool()]); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs index b130a3e2a0cc..c58d2fd1b89b 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.Agents.OpenAI; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -63,7 +64,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -80,17 +81,17 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var agent = new OpenAIClient(apiKey).GetResponsesClient().AsIChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + var agent = new OpenAIClient(apiKey).GetResponsesClient() + .AsAIAgent(model: model, name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs index d7519abd347d..9face250635a 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs @@ -6,6 +6,7 @@ using Microsoft.SemanticKernel.Agents.OpenAI; using Microsoft.SemanticKernel.ChatCompletion; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -131,7 +132,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -176,10 +177,10 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var agent = new OpenAIClient(apiKey).GetResponsesClient().AsIChatClient(model) - .CreateAIAgent(name: "Thinker", instructions: "You are at thinking hard before answering."); + var agent = new OpenAIClient(apiKey).GetResponsesClient() + .AsAIAgent(model: model, name: "Thinker", instructions: "You are at thinking hard before answering."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -190,7 +191,7 @@ async Task AFAgentAsync() } }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); // Retrieve the thinking as a full text block requires flattening multiple TextReasoningContents from multiple messages content lists. string assistantThinking = string.Join("\n", result.Messages @@ -203,7 +204,7 @@ async Task AFAgentAsync() Console.WriteLine($"Assistant: \n{assistantText}\n---\n"); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { var thinkingContents = update.Contents .OfType() diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs index ff9fdae2d4d6..14fa1c601beb 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -63,7 +64,8 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgentAsync() { - var agent = new OpenAIClient(apiKey).GetResponsesClient().AsIChatClient(model).CreateAIAgent( + var agent = new OpenAIClient(apiKey).GetResponsesClient().AsAIAgent( + model: model, instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs index 403ec70e6575..36245131e702 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel.Agents.OpenAI; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -65,8 +66,8 @@ async Task AFAgentAsync() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new OpenAIClient(apiKey) - .GetResponsesClient().AsIChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .GetResponsesClient() + .AsAIAgent(model: model, name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs index 08407e0ae801..1408c5d21814 100644 --- a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs +++ b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs @@ -50,7 +50,7 @@ public SemanticKernelAIAgent( } /// - public override string Id => this._innerAgent.Id; + protected override string? IdCore => this._innerAgent.Id; /// public override string? Name => this._innerAgent.Name; @@ -59,19 +59,31 @@ public SemanticKernelAIAgent( public override string? Description => this._innerAgent.Description; /// - public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) - => new SemanticKernelAIAgentThread(this._threadDeserializationFactory(serializedThread, jsonSerializerOptions), this._threadSerializer); + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new SemanticKernelAIAgentSession(this._threadFactory())); /// - public override MAAI.AgentThread GetNewThread() => new SemanticKernelAIAgentThread(this._threadFactory(), this._threadSerializer); + protected override ValueTask SerializeSessionCoreAsync(MAAI.AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not SemanticKernelAIAgentSession typedSession) + { + throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used."); + } + + return new(this._threadSerializer(typedSession.InnerThread, jsonSerializerOptions)); + } + + /// + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new SemanticKernelAIAgentSession(this._threadDeserializationFactory(serializedState, jsonSerializerOptions))); /// - public override async Task RunAsync(IEnumerable messages, MAAI.AgentThread? thread = null, MAAI.AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override async Task RunCoreAsync(IEnumerable messages, MAAI.AgentSession? session = null, MAAI.AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - thread ??= this.GetNewThread(); - if (thread is not SemanticKernelAIAgentThread typedThread) + session ??= await this.CreateSessionCoreAsync(cancellationToken).ConfigureAwait(false); + if (session is not SemanticKernelAIAgentSession typedSession) { - throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); + throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used."); } List responseMessages = []; @@ -98,13 +110,14 @@ public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread, }; AgentResponseItem? lastResponseItem = null; - ChatMessage? lastResponseMessage = null; - await foreach (var responseItem in this._innerAgent.InvokeAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedThread.InnerThread, invokeOptions, cancellationToken).ConfigureAwait(false)) + await foreach (var responseItem in this._innerAgent.InvokeAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedSession.InnerThread, invokeOptions, cancellationToken).ConfigureAwait(false)) { lastResponseItem = responseItem; } - return new MAAI.AgentRunResponse(responseMessages) + var lastResponseMessage = lastResponseItem?.Message.ToChatMessage(); + + return new MAAI.AgentResponse(responseMessages) { AgentId = this._innerAgent.Id, RawRepresentation = lastResponseItem, @@ -114,23 +127,23 @@ public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread, } /// - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, - MAAI.AgentThread? thread = null, + MAAI.AgentSession? session = null, MAAI.AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - thread ??= this.GetNewThread(); - if (thread is not SemanticKernelAIAgentThread typedThread) + session ??= await this.CreateSessionCoreAsync(cancellationToken).ConfigureAwait(false); + if (session is not SemanticKernelAIAgentSession typedSession) { - throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); + throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used."); } - await foreach (var responseItem in this._innerAgent.InvokeStreamingAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedThread.InnerThread, cancellationToken: cancellationToken).ConfigureAwait(false)) + await foreach (var responseItem in this._innerAgent.InvokeStreamingAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedSession.InnerThread, cancellationToken: cancellationToken).ConfigureAwait(false)) { var update = responseItem.Message.ToChatResponseUpdate(); - yield return new MAAI.AgentRunResponseUpdate + yield return new MAAI.AgentResponseUpdate { AuthorName = update.AuthorName, AgentId = this._innerAgent.Id, diff --git a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentThread.cs b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentSession.cs similarity index 56% rename from dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentThread.cs rename to dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentSession.cs index 274a9a839cf0..86a8d6557adf 100644 --- a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentThread.cs +++ b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentSession.cs @@ -2,23 +2,17 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using MAAI = Microsoft.Agents.AI; namespace Microsoft.SemanticKernel.Agents; [Experimental("SKEXP0110")] -internal sealed class SemanticKernelAIAgentThread : MAAI.AgentThread +internal sealed class SemanticKernelAIAgentSession : MAAI.AgentSession { - private readonly Func _threadSerializer; - - internal SemanticKernelAIAgentThread(AgentThread thread, Func threadSerializer) + internal SemanticKernelAIAgentSession(AgentThread thread) { Throw.IfNull(thread); - Throw.IfNull(threadSerializer); - this.InnerThread = thread; - this._threadSerializer = threadSerializer; } /// @@ -26,10 +20,6 @@ internal SemanticKernelAIAgentThread(AgentThread thread, Func public AgentThread InnerThread { get; } - /// - public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) - => this._threadSerializer(this.InnerThread, jsonSerializerOptions); - /// public override object? GetService(Type serviceType, object? serviceKey = null) { diff --git a/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs index f85ee5ca0fee..45d587d3e7b8 100644 --- a/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs @@ -3,11 +3,11 @@ using System; using System.Net.Http; using System.Text.Json; +using System.Threading.Tasks; using A2A; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.A2A; using Xunit; - namespace SemanticKernel.Agents.UnitTests.A2A; public sealed class A2AAgentExtensionsTests @@ -40,7 +40,7 @@ public void AsAIAgent_WithNullA2AAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange using var httpClient = new HttpClient(); @@ -50,17 +50,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = a2aAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange using var httpClient = new HttpClient(); @@ -71,17 +71,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = a2aAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange using var httpClient = new HttpClient(); @@ -93,18 +93,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = a2aAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange using var httpClient = new HttpClient(); @@ -116,10 +116,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = a2aAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentThreadTests.cs b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentSessionTests.cs similarity index 51% rename from dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentThreadTests.cs rename to dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentSessionTests.cs index 8f730a8e22ef..d23fcc8d838e 100644 --- a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentThreadTests.cs +++ b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentSessionTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel; @@ -11,76 +10,33 @@ namespace SemanticKernel.Agents.UnitTests.AIAgent; -public sealed class SemanticKernelAIAgentThreadTests +public sealed class SemanticKernelAIAgentSessionTests { [Fact] public void Constructor_InitializesProperties() { // Arrange var threadMock = new Mock(); - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) => default; // Act - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Assert Assert.Equal(threadMock.Object, adapter.InnerThread); } - [Fact] - public void Serialize_CallsThreadSerializer() - { - // Arrange - var threadMock = new Mock(); - var serializerCallCount = 0; - var expectedJsonElement = JsonElement.Parse("{\"test\": \"value\"}"); - - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) - { - serializerCallCount++; - Assert.Same(threadMock.Object, t); - return expectedJsonElement; - } - - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); - - // Act - var result = adapter.Serialize(); - - // Assert - Assert.Equal(1, serializerCallCount); - Assert.Equal(expectedJsonElement.ToString(), result.ToString()); - } - - [Fact] - public void Serialize_WithJsonSerializerOptions_PassesOptionsToSerializer() - { - // Arrange - var threadMock = new Mock(); - var expectedOptions = new JsonSerializerOptions(); - JsonSerializerOptions? capturedOptions = null; - - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) - { - capturedOptions = o; - return default; - } - - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); + // AF 1.0: Serialize_CallsThreadSerializer test removed - serialization moved to agent. + // See SemanticKernelAIAgentTests for serialization coverage. - // Act - adapter.Serialize(expectedOptions); - - // Assert - Assert.Same(expectedOptions, capturedOptions); - } + // AF 1.0: Serialize() moved from AgentSession to AIAgent.SerializeSessionCoreAsync(). + // These tests are covered by SemanticKernelAIAgentTests instead. [Fact] public void GetService_WithAgentThreadType_ReturnsInnerThread() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(AgentThread)); @@ -94,7 +50,7 @@ public void GetService_WithAgentThreadTypeAndServiceKey_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); var serviceKey = new object(); // Act @@ -109,7 +65,7 @@ public void GetService_WithNonAgentThreadType_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(string)); @@ -123,56 +79,29 @@ public void GetService_WithNullType_ThrowsArgumentNullException() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act & Assert Assert.Throws(() => adapter.GetService(null!)); } - [Fact] - public void Serialize_WithNullOptions_CallsSerializerWithNull() - { - // Arrange - var threadMock = new Mock(); - JsonSerializerOptions? capturedOptions = new(); - - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) - { - capturedOptions = o; - return default; - } - - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); - - // Act - adapter.Serialize(null); - - // Assert - Assert.Null(capturedOptions); - } + // AF 1.0: Serialize_WithNullOptions test removed - serialization moved to agent. [Fact] public void Constructor_WithNullThread_ThrowsArgumentNullException() { // Arrange & Act - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) => default; - Assert.Throws(() => new SemanticKernelAIAgentThread(null!, ThreadSerializer)); + Assert.Throws(() => new SemanticKernelAIAgentSession(null!)); } - [Fact] - public void Constructor_WithNullSerializer_ThrowsArgumentNullException() - { - // Arrange & Act - var threadMock = new Mock(); - Assert.Throws(() => new SemanticKernelAIAgentThread(threadMock.Object, null!)); - } + // Constructor_WithNullSerializer test removed: serializer is no longer stored on the session. [Fact] public void GetService_WithBaseClassType_ReturnsInnerThread() { // Arrange var concreteThread = new TestAgentThread(); - var adapter = new SemanticKernelAIAgentThread(concreteThread, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(concreteThread); // Act var result = adapter.GetService(typeof(AgentThread)); @@ -186,7 +115,7 @@ public void GetService_WithDerivedType_ReturnsInnerThreadWhenMatches() { // Arrange var concreteThread = new TestAgentThread(); - var adapter = new SemanticKernelAIAgentThread(concreteThread, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(concreteThread); // Act var result = adapter.GetService(typeof(TestAgentThread)); @@ -200,7 +129,7 @@ public void GetService_WithIncompatibleDerivedType_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(TestAgentThread)); @@ -214,7 +143,7 @@ public void GetService_WithInterfaceType_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(IServiceProvider)); diff --git a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs index ebb271a4cef7..ed178c4be09d 100644 --- a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs +++ b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs @@ -84,7 +84,7 @@ public void Constructor_WithNullThreadSerializer_ThrowsArgumentNullException() } [Fact] - public void DeserializeThread_ReturnsSemanticKernelAIAgentThread() + public async Task DeserializeThread_ReturnsSemanticKernelAIAgentSession() { // Arrange var agentMock = new Mock(); @@ -95,14 +95,14 @@ public void DeserializeThread_ReturnsSemanticKernelAIAgentThread() var json = JsonElement.Parse("{}"); // Act - var result = adapter.DeserializeThread(json); + var result = await adapter.DeserializeSessionAsync(json); // Assert - Assert.IsType(result); + Assert.IsType(result); } [Fact] - public void GetNewThread_ReturnsSemanticKernelAIAgentThread() + public async Task GetNewThread_ReturnsSemanticKernelAIAgentSession() { // Arrange var agentMock = new Mock(); @@ -111,15 +111,15 @@ public void GetNewThread_ReturnsSemanticKernelAIAgentThread() var adapter = new SemanticKernelAIAgent(agentMock.Object, () => expectedThread, (e, o) => expectedThread, ThreadSerializer); // Act - var result = adapter.GetNewThread(); + var result = await adapter.CreateSessionAsync(); // Assert - Assert.IsType(result); - Assert.Equal(expectedThread, ((SemanticKernelAIAgentThread)result).InnerThread); + Assert.IsType(result); + Assert.Equal(expectedThread, ((SemanticKernelAIAgentSession)result).InnerThread); } [Fact] - public void DeserializeThread_CallsDeserializationFactory() + public async Task DeserializeThread_CallsDeserializationFactory() { // Arrange var agentMock = new Mock(); @@ -136,15 +136,15 @@ AgentThread DeserializationFactory(JsonElement e, JsonSerializerOptions? o) var json = JsonElement.Parse("{}"); // Act - var result = adapter.DeserializeThread(json); + var result = await adapter.DeserializeSessionAsync(json); // Assert Assert.Equal(1, factoryCallCount); - Assert.IsType(result); + Assert.IsType(result); } [Fact] - public void GetNewThread_CallsThreadFactory() + public async Task GetNewThread_CallsThreadFactory() { // Arrange var agentMock = new Mock(); @@ -160,11 +160,11 @@ AgentThread ThreadFactory() var adapter = new SemanticKernelAIAgent(agentMock.Object, ThreadFactory, (e, o) => expectedThread, (t, o) => default); // Act - var result = adapter.GetNewThread(); + var result = await adapter.CreateSessionAsync(); // Assert Assert.Equal(1, factoryCallCount); - Assert.IsType(result); + Assert.IsType(result); } [Fact] @@ -211,13 +211,13 @@ async IAsyncEnumerable> MockInvokeAsync(IC yield return new AgentResponseItem(message, innerThread); } - var thread = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var thread = new SemanticKernelAIAgentSession(innerThread); // Act var result = await adapter.RunAsync("Input text", thread); // Assert - Assert.IsType(result); + Assert.IsType(result); Assert.Equal("Final response", result.Text); agentMock.Verify(a => a.InvokeAsync( It.Is>(x => x.First().Content == "Input text"), @@ -246,13 +246,13 @@ async IAsyncEnumerable> GetAsyncE yield return new AgentResponseItem(new StreamingChatMessageContent(AuthorRole.Assistant, "Final response"), innerThread); } - var thread = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var thread = new SemanticKernelAIAgentSession(innerThread); // Act var results = await adapter.RunStreamingAsync("Input text", thread).ToListAsync(); // Assert - Assert.IsType(results.First()); + Assert.IsType(results.First()); Assert.Equal("Final response", results.First().Text); agentMock.Verify(a => a.InvokeStreamingAsync( It.Is>(x => x.First().Content == "Input text"), @@ -288,13 +288,13 @@ async IAsyncEnumerable> GetEnumerableWithD yield return new AgentResponseItem(final, thread); } - var threadWrapper = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var threadWrapper = new SemanticKernelAIAgentSession(innerThread); // Act var response = await adapter.RunAsync("input", threadWrapper); // Assert - // Use reflection to inspect Messages collection inside AgentRunResponse + // Use reflection to inspect Messages collection inside AgentResponse var messages = response.Messages; var contents = messages.First().Contents; Assert.Single(contents); // Duplicate text content should have been removed @@ -327,7 +327,7 @@ async IAsyncEnumerable> GetEnumerableWithN yield return new AgentResponseItem(final, thread); } - var threadWrapper = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var threadWrapper = new SemanticKernelAIAgentSession(innerThread); // Act var response = await adapter.RunAsync("input", threadWrapper); diff --git a/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs index 158dd689e7c2..191b475cdab5 100644 --- a/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs @@ -2,10 +2,10 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Moq; using Xunit; - namespace SemanticKernel.Agents.UnitTests; public sealed class AgentExtensionsTests @@ -77,7 +77,7 @@ public void AsAIAgent_WithNullThreadSerializer_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_WithValidFactories_CreatesWorkingAdapter() + public async Task AsAIAgent_WithValidFactories_CreatesWorkingAdapter() { // Arrange var agentMock = new Mock(); @@ -95,17 +95,17 @@ AgentThread ThreadFactory() // Act var result = agentMock.Object.AsAIAgent(ThreadFactory, ThreadDeserializationFactory, ThreadSerializer); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); Assert.Equal(1, factoryCallCount); - Assert.IsType(thread); - Assert.Same(expectedThread, ((SemanticKernelAIAgentThread)thread).InnerThread); + Assert.IsType(thread); + Assert.Same(expectedThread, ((SemanticKernelAIAgentSession)thread).InnerThread); } [Fact] - public void AsAIAgent_WithDeserializationFactory_CreatesWorkingAdapter() + public async Task AsAIAgent_WithDeserializationFactory_CreatesWorkingAdapter() { // Arrange var agentMock = new Mock(); @@ -125,12 +125,12 @@ AgentThread ThreadDeserializationFactory(JsonElement e, JsonSerializerOptions? o // Act var result = agentMock.Object.AsAIAgent(ThreadFactory, ThreadDeserializationFactory, ThreadSerializer); var json = JsonElement.Parse("{}"); - var thread = result.DeserializeThread(json); + var thread = await result.DeserializeSessionAsync(json); // Assert Assert.NotNull(thread); Assert.Equal(1, deserializationCallCount); - Assert.IsType(thread); - Assert.Same(expectedThread, ((SemanticKernelAIAgentThread)thread).InnerThread); + Assert.IsType(thread); + Assert.Same(expectedThread, ((SemanticKernelAIAgentSession)thread).InnerThread); } } diff --git a/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs index 03dbf1c80b5d..aaf62c7fc921 100644 --- a/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs @@ -3,12 +3,12 @@ using System; using System.ClientModel.Primitives; using System.Text.Json; +using System.Threading.Tasks; using Azure.AI.Agents.Persistent; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.AzureAI; using Moq; using Xunit; - namespace SemanticKernel.Agents.UnitTests.AzureAI; public sealed class AzureAIAgentExtensionsTests @@ -49,24 +49,24 @@ public void AsAIAgent_WithNullAzureAIAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { var clientMock = new Mock(); var azureAIAgent = new AzureAIAgent(s_agentMetadata, clientMock.Object); // Act var result = azureAIAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var clientMock = new Mock(); @@ -75,17 +75,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = azureAIAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var clientMock = new Mock(); @@ -96,18 +96,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = azureAIAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var clientMock = new Mock(); @@ -118,10 +118,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = azureAIAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs index fcd68bf90a76..a7871194aeb3 100644 --- a/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs @@ -59,7 +59,7 @@ public void AsAIAgent_WithNullBedrockAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -67,17 +67,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = bedrockAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -86,17 +86,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = bedrockAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -106,17 +106,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = bedrockAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -126,10 +126,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = bedrockAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs index 73185ac6d69f..aa666cad688f 100644 --- a/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs @@ -2,12 +2,12 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.Agents.CopilotStudio.Client; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.Copilot; using Moq; using Xunit; - namespace SemanticKernel.Agents.UnitTests.Copilot; public sealed class CopilotStudioAgentExtensionsTests @@ -38,7 +38,7 @@ public void AsAIAgent_WithNullCopilotStudioAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -46,17 +46,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = copilotStudioAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -65,17 +65,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = copilotStudioAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -85,17 +85,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = copilotStudioAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -105,10 +105,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = copilotStudioAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs index 31bae18429f5..e47675307e9a 100644 --- a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs @@ -2,10 +2,10 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; using Xunit; - namespace SemanticKernel.Agents.UnitTests.Core; public sealed class ChatCompletionAgentExtensionsTests @@ -39,7 +39,7 @@ public void AsAIAgent_WithNullChatCompletionAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -50,17 +50,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = chatCompletionAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullChatHistory_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullChatHistory_CreatesNewThread() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -72,17 +72,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullChatHistory_CreatesNe // Act var result = chatCompletionAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidChatHistory_CreatesThreadWithHistory() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidChatHistory_CreatesThreadWithHistory() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -98,19 +98,19 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidChatHistory_CreatesT // Act var result = chatCompletionAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); var chatHistoryThread = (ChatHistoryAgentThread)threadAdapter.InnerThread; Assert.Equal(2, chatHistoryThread.ChatHistory.Count); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesChatHistory() + public async Task AsAIAgent_ThreadSerializer_SerializesChatHistory() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -126,10 +126,10 @@ public void AsAIAgent_ThreadSerializer_SerializesChatHistory() var jsonElement = JsonSerializer.SerializeToElement(chatHistory); var result = chatCompletionAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.Array, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs index bd6493907b28..7c62e0037550 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs @@ -3,12 +3,12 @@ using System; using System.ClientModel.Primitives; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.OpenAI; using Moq; using OpenAI.Assistants; using Xunit; - namespace SemanticKernel.Agents.UnitTests.OpenAI; public sealed class OpenAIAssistantAgentExtensionsTests @@ -54,7 +54,7 @@ public void AsAIAgent_WithNullOpenAIAssistantAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var clientMock = new Mock(); @@ -62,17 +62,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = assistantAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var clientMock = new Mock(); @@ -81,17 +81,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = assistantAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var clientMock = new Mock(); @@ -101,18 +101,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = assistantAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var clientMock = new Mock(); @@ -122,10 +122,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = assistantAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs index 8f124b33a49f..197e333c240d 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs @@ -3,12 +3,12 @@ using System; using System.ClientModel; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.OpenAI; using Microsoft.SemanticKernel.ChatCompletion; using OpenAI.Responses; using Xunit; - namespace SemanticKernel.Agents.UnitTests.OpenAI; public sealed class OpenAIResponseAgentExtensionsTests @@ -39,7 +39,7 @@ public void AsAIAgent_WithNullOpenAIResponseAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactoryStoreTrue() + public async Task AsAIAgent_CreatesWorkingThreadFactoryStoreTrue() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -50,17 +50,17 @@ public void AsAIAgent_CreatesWorkingThreadFactoryStoreTrue() // Act var result = responseAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactoryStoreFalse() + public async Task AsAIAgent_CreatesWorkingThreadFactoryStoreFalse() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -71,17 +71,17 @@ public void AsAIAgent_CreatesWorkingThreadFactoryStoreFalse() // Act var result = responseAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -93,17 +93,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -116,18 +116,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -140,10 +140,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); @@ -151,7 +151,7 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullJson_CreatesThreadWithEmptyChatHistory() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullJson_CreatesThreadWithEmptyChatHistory() { var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); var responseAgent = new OpenAIResponseAgent(responseClient); @@ -159,18 +159,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullJson_CreatesThreadWit // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; var chatHistoryAgentThread = Assert.IsType(threadAdapter.InnerThread); Assert.Empty(chatHistoryAgentThread.ChatHistory); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThreadWithChatHistory() + public async Task AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThreadWithChatHistory() { var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); var responseAgent = new OpenAIResponseAgent(responseClient); @@ -179,12 +179,12 @@ public void AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThread // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; var chatHistoryAgentThread = Assert.IsType(threadAdapter.InnerThread); Assert.Single(chatHistoryAgentThread.ChatHistory); var firstMessage = chatHistoryAgentThread.ChatHistory[0]; @@ -193,7 +193,7 @@ public void AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThread } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesChatHistory() + public async Task AsAIAgent_ThreadSerializer_SerializesChatHistory() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -202,10 +202,10 @@ public void AsAIAgent_ThreadSerializer_SerializesChatHistory() var jsonElement = JsonSerializer.SerializeToElement(expectedChatHistory); var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.Array, serializedElement.ValueKind); diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs index 7d814645d929..25dbf088986f 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs @@ -59,6 +59,20 @@ public async Task FunctionUsageMetricsLoggingHasAllNeededData() [Fact] public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() { + // Arrange: Set up the response and create the function FIRST so we can filter + // measurements by this specific function's name (avoids parallel test contamination). + this._multiMessageHandlerStub.ResponsesToReturn.Add( + new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } + ); + + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(this._mockLoggerFactory.Object); + builder.AddAzureOpenAIChatCompletion(deploymentName: "model", endpoint: "https://localhost", apiKey: "apiKey", httpClient: this._httpClient); + var kernel = builder.Build(); + + var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); + var expectedFunctionName = kernelFunction.Name; + // Set up a MeterListener to capture the measurements using MeterListener listener = new(); var isPublished = false; @@ -81,10 +95,23 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { - if (instrument.Name is "semantic_kernel.function.invocation.token_usage.prompt" or - "semantic_kernel.function.invocation.token_usage.completion") + if (instrument.Name is not ("semantic_kernel.function.invocation.token_usage.prompt" or + "semantic_kernel.function.invocation.token_usage.completion")) { - measurements[instrument.Name].Add(measurement); + return; + } + + // Filter by function name tag to ignore measurements emitted by other tests + // that may run in parallel against the same global static histogram. + foreach (var tag in tags) + { + if (tag.Key == "semantic_kernel.function.name" && + tag.Value is string fnName && + fnName == expectedFunctionName) + { + measurements[instrument.Name].Add(measurement); + return; + } } }); @@ -100,17 +127,6 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.Start(); // Start the listener to begin collecting data - this._multiMessageHandlerStub.ResponsesToReturn.Add( - new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } - ); - - var builder = Kernel.CreateBuilder(); - builder.Services.AddSingleton(this._mockLoggerFactory.Object); - builder.AddAzureOpenAIChatCompletion(deploymentName: "model", endpoint: "https://localhost", apiKey: "apiKey", httpClient: this._httpClient); - var kernel = builder.Build(); - - var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); - // Act & Assert var result = await kernel.InvokeAsync(kernelFunction); diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs index 03125bf0a3ae..2786d4ab9261 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs @@ -60,6 +60,20 @@ public async Task FunctionUsageMetricsLoggingHasAllNeededData() [Fact] public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() { + // Arrange: Set up the response and create the function FIRST so we can filter + // measurements by this specific function's name (avoids parallel test contamination). + this._multiMessageHandlerStub.ResponsesToReturn.Add( + new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } + ); + + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(this._mockLoggerFactory.Object); + builder.AddOpenAIChatCompletion(modelId: "model", apiKey: "apiKey", httpClient: this._httpClient); + var kernel = builder.Build(); + + var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); + var expectedFunctionName = kernelFunction.Name; + // Set up a MeterListener to capture the measurements using MeterListener listener = new(); var isPublished = false; @@ -82,10 +96,23 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { - if (instrument.Name is "semantic_kernel.function.invocation.token_usage.prompt" or - "semantic_kernel.function.invocation.token_usage.completion") + if (instrument.Name is not ("semantic_kernel.function.invocation.token_usage.prompt" or + "semantic_kernel.function.invocation.token_usage.completion")) { - measurements[instrument.Name].Add(measurement); + return; + } + + // Filter by function name tag to ignore measurements emitted by other tests + // that may run in parallel against the same global static histogram. + foreach (var tag in tags) + { + if (tag.Key == "semantic_kernel.function.name" && + tag.Value is string fnName && + fnName == expectedFunctionName) + { + measurements[instrument.Name].Add(measurement); + return; + } } }); @@ -101,17 +128,6 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.Start(); // Start the listener to begin collecting data - this._multiMessageHandlerStub.ResponsesToReturn.Add( - new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } - ); - - var builder = Kernel.CreateBuilder(); - builder.Services.AddSingleton(this._mockLoggerFactory.Object); - builder.AddOpenAIChatCompletion(modelId: "model", apiKey: "apiKey", httpClient: this._httpClient); - var kernel = builder.Build(); - - var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); - // Act & Assert var result = await kernel.InvokeAsync(kernelFunction); diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs index 41aaf3797e26..950955ca2ebf 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs @@ -18,16 +18,16 @@ public abstract class SemanticKernelAIAgentTests(Func createAgentF public virtual async Task ConvertAndRunAgentAsync() { var aiagent = this.Fixture.AIAgent; - var thread = aiagent.GetNewThread(); + var session = await aiagent.CreateSessionAsync(); - var result = await aiagent.RunAsync("What is the capital of France?", thread); + var result = await aiagent.RunAsync("What is the capital of France?", session); Assert.Contains("Paris", result.Text, StringComparison.OrdinalIgnoreCase); - var serialisedTreadJsonElement = thread.Serialize(); + var serialisedSessionJsonElement = await aiagent.SerializeSessionAsync(session); - var deserializedThread = aiagent.DeserializeThread(serialisedTreadJsonElement); + var deserializedSession = await aiagent.DeserializeSessionAsync(serialisedSessionJsonElement); - var secondResult = await aiagent.RunAsync("And Austria?", deserializedThread); + var secondResult = await aiagent.RunAsync("And Austria?", deserializedSession); Assert.Contains("Vienna", secondResult.Text, StringComparison.OrdinalIgnoreCase); } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs index d7976ad2dbd0..3c2accd5942b 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs @@ -179,15 +179,17 @@ public static class PromptExecutionSettingsExtensions options.ToolMode = ChatToolMode.Auto; options.AllowMultipleToolCalls = autoChoiceBehavior.Options?.AllowParallelCalls; } - else if (settings.FunctionChoiceBehavior is NoneFunctionChoiceBehavior noneFunctionChoiceBehavior) - { - options.ToolMode = ChatToolMode.None; - } - else if (settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior requiredFunctionChoiceBehavior) - { - options.ToolMode = ChatToolMode.RequireAny; - options.AllowMultipleToolCalls = requiredFunctionChoiceBehavior.Options?.AllowParallelCalls; - } + else + if (settings.FunctionChoiceBehavior is NoneFunctionChoiceBehavior noneFunctionChoiceBehavior) + { + options.ToolMode = ChatToolMode.None; + } + else + if (settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior requiredFunctionChoiceBehavior) + { + options.ToolMode = ChatToolMode.RequireAny; + options.AllowMultipleToolCalls = requiredFunctionChoiceBehavior.Options?.AllowParallelCalls; + } options.Tools = []; foreach (var function in functions) From a7cea9ab64cf9ffda29e2bdc7d2b6279c7500e5d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 13 May 2026 09:35:56 +0100 Subject: [PATCH 29/32] Python: Stop accessing private Azure SDK attributes in Azure AI Search connector (#13971) ### Motivation and Context CI fails with `AttributeError: 'SearchIndexClient' object has no attribute '_endpoint'` when `azure-search-documents` resolves to 12.0.0 (released 2026-04-01). The 12.0.0 release migrated from AutoRest to TypeSpec code generation, removing private attributes `_endpoint` and `_credential` from `SearchIndexClient`. CI uses `uv sync -U` which upgrades past the lockfile, pulling in 12.0.0 since `pyproject.toml` specifies `>= 11.6.0b4` with no upper bound. ### Description Stop reading `SearchIndexClient._endpoint` and `._credential`. Instead, store endpoint and credential as explicit fields on `AzureAISearchStore` and `AzureAISearchCollection`, and pass them directly when constructing `SearchClient`. **Changes:** - `_get_search_client()` accepts explicit `endpoint` + `credential` params instead of extracting from `SearchIndexClient` private attrs - `_resolve_credential()` new helper extracted from `_get_search_index_client()` for reuse - `AzureAISearchStore` / `AzureAISearchCollection` store `search_endpoint` and `search_credential` as fields, thread them through construction - Tests updated to assert on public fields instead of private SDK attributes ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] All unit tests pass with both `azure-search-documents==11.7.0b2` and `==12.0.0` - [x] No breaking changes to public API --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/pyproject.toml | 6 +- .../connectors/azure_ai_search.py | 101 ++++++++++++++---- .../connectors/memory/test_azure_ai_search.py | 57 ++++++++-- python/uv.lock | 2 +- 4 files changed, 133 insertions(+), 33 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 3383b6db5f1d..8a0deef93a72 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -103,7 +103,7 @@ milvus = [ "milvus >= 2.3,<2.3.8; platform_system != 'Windows'" ] mistralai = [ - "mistralai >= 1.2,< 2.0" + "mistralai >= 1.2,< 2.4.6" ] mongo = [ "pymongo >= 4.8.0, < 4.16", @@ -118,9 +118,7 @@ ollama = [ onnx = [ # onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10. "onnxruntime==1.22.1; python_version == '3.10'", - # onnxruntime 1.26.0 has no macOS ARM64 wheel; pin to last compatible version. - # https://github.com/microsoft/onnxruntime/issues/28441 - "onnxruntime>=1.24.3, <1.26.0; python_version > '3.10'", + "onnxruntime>=1.24.3; python_version > '3.10'", "onnxruntime-genai==0.9.0" ] oracledb = [ diff --git a/python/semantic_kernel/connectors/azure_ai_search.py b/python/semantic_kernel/connectors/azure_ai_search.py index 8a469b6aa2b2..f01bef97a96f 100644 --- a/python/semantic_kernel/connectors/azure_ai_search.py +++ b/python/semantic_kernel/connectors/azure_ai_search.py @@ -7,7 +7,7 @@ from collections.abc import Sequence from typing import Any, ClassVar, Final, Generic, TypeVar -from azure.core.credentials import AzureKeyCredential, TokenCredential +from azure.core.credentials import AzureKeyCredential from azure.core.credentials_async import AsyncTokenCredential from azure.search.documents.aio import SearchClient from azure.search.documents.indexes.aio import SearchIndexClient @@ -149,23 +149,47 @@ class AzureAISearchSettings(KernelBaseSettings): def _get_search_client( - search_index_client: SearchIndexClient, collection_name: str | None, **kwargs: Any + endpoint: str, + collection_name: str | None, + credential: "AzureKeyCredential | AsyncTokenCredential", + **kwargs: Any, ) -> SearchClient: """Create a search client for a collection.""" if not collection_name: raise VectorStoreInitializationException("Collection name is required to create a search client.") try: - return SearchClient(search_index_client._endpoint, collection_name, search_index_client._credential, **kwargs) + return SearchClient(endpoint, collection_name, credential, **kwargs) except ValueError as exc: raise VectorStoreInitializationException( f"Failed to create Azure Cognitive Search client for collection {collection_name}." ) from exc +def _resolve_credential( + azure_ai_search_settings: AzureAISearchSettings, + azure_credential: AzureKeyCredential | None = None, + token_credential: "AsyncTokenCredential | None" = None, +) -> "AzureKeyCredential | AsyncTokenCredential": + """Resolve the credential to use for Azure AI Search. + + Args: + azure_ai_search_settings: Azure AI Search settings. + azure_credential: Optional Azure credentials (default: {None}). + token_credential: Optional Token credential (default: {None}). + """ + if azure_credential: + return azure_credential + if token_credential: + return token_credential + if azure_ai_search_settings.api_key: + return AzureKeyCredential(azure_ai_search_settings.api_key.get_secret_value()) + raise ServiceInitializationError("Error: missing Azure AI Search client credentials.") + + def _get_search_index_client( azure_ai_search_settings: AzureAISearchSettings, azure_credential: AzureKeyCredential | None = None, - token_credential: "AsyncTokenCredential | TokenCredential | None" = None, + token_credential: "AsyncTokenCredential | None" = None, ) -> SearchIndexClient: """Return a client for Azure AI Search. @@ -174,20 +198,11 @@ def _get_search_index_client( azure_credential: Optional Azure credentials (default: {None}). token_credential: Optional Token credential (default: {None}). """ - # Credentials - credential: "AzureKeyCredential | AsyncTokenCredential | TokenCredential | None" = None - if azure_credential: - credential = azure_credential - elif token_credential: - credential = token_credential - elif azure_ai_search_settings.api_key: - credential = AzureKeyCredential(azure_ai_search_settings.api_key.get_secret_value()) - else: - raise ServiceInitializationError("Error: missing Azure AI Search client credentials.") + credential = _resolve_credential(azure_ai_search_settings, azure_credential, token_credential) return SearchIndexClient( endpoint=str(azure_ai_search_settings.endpoint), - credential=credential, # type: ignore + credential=credential, headers=prepend_semantic_kernel_to_user_agent({}) if APP_INFO else None, ) @@ -286,6 +301,8 @@ class AzureAISearchCollection( search_client: SearchClient search_index_client: SearchIndexClient + search_endpoint: str | None = None + search_credential: Any = None supported_key_types: ClassVar[set[str] | None] = {"str"} supported_vector_types: ClassVar[set[str] | None] = {"float", "int"} supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR, SearchType.KEYWORD_HYBRID} @@ -299,6 +316,7 @@ def __init__( search_index_client: SearchIndexClient | None = None, search_client: SearchClient | None = None, embedding_generator: "EmbeddingGeneratorBase | None" = None, + search_credential: "AzureKeyCredential | AsyncTokenCredential | None" = None, **kwargs: Any, ) -> None: """Initializes a new instance of the AzureAISearchCollection class. @@ -319,13 +337,16 @@ def __init__( used for creating and deleting indexes. search_client: The search client for interacting with Azure AI Search, used for record operations. + search_credential: The credential used to authenticate with Azure AI Search. + If not provided, it will be resolved from azure_credentials, token_credentials, + or api_key in kwargs/environment. embedding_generator: The embedding generator, optional. **kwargs: Additional keyword arguments, including: The same keyword arguments used for AzureAISearchVectorStore: - search_endpoint: str | None = None, + search_endpoint: The endpoint of the Azure AI Search service, optional. api_key: str | None = None, azure_credentials: AzureKeyCredential | None = None, - token_credentials: AsyncTokenCredential | TokenCredential | None = None, + token_credentials: AsyncTokenCredential | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None @@ -343,6 +364,8 @@ def __init__( collection_name=collection_name, search_client=search_client, search_index_client=search_index_client, + search_endpoint=kwargs.get("search_endpoint"), + search_credential=search_credential, managed_search_index_client=False, managed_client=False, embedding_generator=embedding_generator, @@ -360,14 +383,24 @@ def __init__( ) except ValidationError as exc: raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc + endpoint = str(azure_ai_search_settings.endpoint) + credential = search_credential or _resolve_credential( + azure_ai_search_settings, + azure_credential=kwargs.get("azure_credentials"), + token_credential=kwargs.get("token_credentials"), + ) super().__init__( record_type=record_type, definition=definition, collection_name=azure_ai_search_settings.index_name, search_client=_get_search_client( - search_index_client=search_index_client, collection_name=azure_ai_search_settings.index_name + endpoint=endpoint, + collection_name=azure_ai_search_settings.index_name, + credential=credential, ), search_index_client=search_index_client, + search_endpoint=endpoint, + search_credential=credential, managed_search_index_client=False, embedding_generator=embedding_generator, ) @@ -383,6 +416,12 @@ def __init__( ) except ValidationError as exc: raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc + endpoint = str(azure_ai_search_settings.endpoint) + credential = search_credential or _resolve_credential( + azure_ai_search_settings, + azure_credential=kwargs.get("azure_credentials"), + token_credential=kwargs.get("token_credentials"), + ) search_index_client = _get_search_index_client( azure_ai_search_settings=azure_ai_search_settings, azure_credential=kwargs.get("azure_credentials"), @@ -393,10 +432,13 @@ def __init__( definition=definition, collection_name=azure_ai_search_settings.index_name, search_client=_get_search_client( - search_index_client=search_index_client, - collection_name=azure_ai_search_settings.index_name, # type: ignore + endpoint=endpoint, + collection_name=azure_ai_search_settings.index_name, + credential=credential, ), search_index_client=search_index_client, + search_endpoint=endpoint, + search_credential=credential, embedding_generator=embedding_generator, ) @@ -711,13 +753,15 @@ class AzureAISearchStore(VectorStore): """Azure AI Search store implementation.""" search_index_client: SearchIndexClient + search_endpoint: str | None = None + search_credential: Any = None def __init__( self, search_endpoint: str | None = None, api_key: str | None = None, azure_credentials: "AzureKeyCredential | None" = None, - token_credentials: "AsyncTokenCredential | TokenCredential | None" = None, + token_credentials: "AsyncTokenCredential | None" = None, search_index_client: SearchIndexClient | None = None, embedding_generator: "EmbeddingGeneratorBase | None" = None, env_file_path: str | None = None, @@ -725,6 +769,8 @@ def __init__( ) -> None: """Initializes a new instance of the AzureAISearchStore class.""" managed_client: bool = False + endpoint: str | None = None + credential: AzureKeyCredential | AsyncTokenCredential | None = None if not search_index_client: try: azure_ai_search_settings = AzureAISearchSettings( @@ -735,15 +781,26 @@ def __init__( ) except ValidationError as exc: raise VectorStoreInitializationException("Failed to create Azure AI Search settings.") from exc + endpoint = str(azure_ai_search_settings.endpoint) + credential = _resolve_credential( + azure_ai_search_settings, + azure_credential=azure_credentials, + token_credential=token_credentials, + ) search_index_client = _get_search_index_client( azure_ai_search_settings=azure_ai_search_settings, azure_credential=azure_credentials, token_credential=token_credentials, ) managed_client = True + else: + endpoint = search_endpoint + credential = azure_credentials or token_credentials or (AzureKeyCredential(api_key) if api_key else None) super().__init__( search_index_client=search_index_client, + search_endpoint=endpoint, + search_credential=credential, managed_client=managed_client, embedding_generator=embedding_generator, ) @@ -777,6 +834,8 @@ def get_collection( search_index_client=self.search_index_client, search_client=search_client, embedding_generator=embedding_generator or self.embedding_generator, + search_credential=self.search_credential, + search_endpoint=self.search_endpoint, **kwargs, ) diff --git a/python/tests/unit/connectors/memory/test_azure_ai_search.py b/python/tests/unit/connectors/memory/test_azure_ai_search.py index 82615ca6c426..d5a8ba2111e0 100644 --- a/python/tests/unit/connectors/memory/test_azure_ai_search.py +++ b/python/tests/unit/connectors/memory/test_azure_ai_search.py @@ -16,6 +16,7 @@ AzureAISearchStore, _definition_to_azure_ai_search_index, _get_search_index_client, + _resolve_credential, ) from semantic_kernel.exceptions import ( ServiceInitializationError, @@ -171,8 +172,6 @@ def test_init_with_search_index_client(azure_ai_search_unit_test_env, definition @mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_INDEX_NAME"]], indirect=True) def test_init_with_search_index_client_fail(azure_ai_search_unit_test_env, definition): search_index_client = MagicMock(spec=SearchIndexClient) - search_index_client._endpoint = "test-endpoint" - search_index_client._credential = "test-credential" with raises(VectorStoreInitializationException): AzureAISearchCollection( record_type=dict, @@ -234,6 +233,7 @@ async def test_ensure_collection_deleted(collection, mock_ensure_collection_dele await collection.ensure_collection_deleted() +@mark.parametrize("distance_function", [("cosine_distance")]) async def test_create_index_from_index(collection, mock_ensure_collection_exists): from azure.search.documents.indexes.models import SearchIndex @@ -241,6 +241,7 @@ async def test_create_index_from_index(collection, mock_ensure_collection_exists await collection.ensure_collection_exists(index=index) +@mark.parametrize("distance_function", [("cosine_distance")]) async def test_create_index_from_definition(collection, mock_ensure_collection_exists): from azure.search.documents.indexes.models import SearchIndex @@ -301,32 +302,74 @@ def test_get_collection(vector_store, definition): assert collection.collection_name == "test" assert collection.search_index_client == vector_store.search_index_client assert collection.search_client is not None - assert collection.search_client._endpoint == vector_store.search_index_client._endpoint + assert collection.search_endpoint == vector_store.search_endpoint + assert collection.search_credential == vector_store.search_credential + + +def test_get_collection_with_provided_search_index_client(azure_ai_search_unit_test_env, definition): + """Test that get_collection works when AzureAISearchStore is created with a pre-built search_index_client. + + When search_index_client is provided directly, search_endpoint and search_credential + are not resolved at store creation time. get_collection() should still succeed + by falling back to environment variables for endpoint/credential resolution. + """ + search_index_client = MagicMock(spec=SearchIndexClient) + store = AzureAISearchStore(search_index_client=search_index_client) + assert store.search_endpoint is None + assert store.search_credential is None + + collection = store.get_collection( + collection_name="test", + record_type=dict, + definition=definition, + ) + assert collection is not None + assert collection.collection_name == "test" + assert collection.search_index_client == search_index_client + assert collection.search_client is not None @mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True) def test_get_search_index_client(azure_ai_search_unit_test_env): - from azure.core.credentials import AzureKeyCredential, TokenCredential + from azure.core.credentials import AzureKeyCredential + from azure.core.credentials_async import AsyncTokenCredential settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env") azure_credential = MagicMock(spec=AzureKeyCredential) client = _get_search_index_client(settings, azure_credential=azure_credential) assert client is not None - assert client._credential == azure_credential - token_credential = MagicMock(spec=TokenCredential) + token_credential = MagicMock(spec=AsyncTokenCredential) client2 = _get_search_index_client( settings, token_credential=token_credential, ) assert client2 is not None - assert client2._credential == token_credential with raises(ServiceInitializationError): _get_search_index_client(settings) +@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True) +def test_resolve_credential(azure_ai_search_unit_test_env): + from azure.core.credentials import AzureKeyCredential + from azure.core.credentials_async import AsyncTokenCredential + + settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env") + + azure_credential = MagicMock(spec=AzureKeyCredential) + resolved = _resolve_credential(settings, azure_credential=azure_credential) + assert resolved == azure_credential + + token_credential = MagicMock(spec=AsyncTokenCredential) + resolved = _resolve_credential(settings, token_credential=token_credential) + assert resolved == token_credential + + with raises(ServiceInitializationError): + _resolve_credential(settings) + + @mark.parametrize("include_vectors", [True, False]) async def test_search_vectorized_search(collection, mock_search, include_vectors): results = await collection.search(vector=[0.1, 0.2, 0.3], include_vectors=include_vectors) diff --git a/python/uv.lock b/python/uv.lock index d1093258d222..10b500f8c457 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -6502,7 +6502,7 @@ requires-dist = [ { name = "numpy", marker = "python_full_version >= '3.12'", specifier = ">=1.26.0" }, { name = "ollama", marker = "extra == 'ollama'", specifier = "~=0.4" }, { name = "onnxruntime", marker = "python_full_version == '3.10.*' and extra == 'onnx'", specifier = "==1.22.1" }, - { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3,<1.26.0" }, + { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3" }, { name = "onnxruntime-genai", marker = "extra == 'onnx'", specifier = "==0.9.0" }, { name = "openai", specifier = ">=2.0.0" }, { name = "openapi-core", specifier = ">=0.18,<0.20" }, From 32abf99e29584cc9f7ba2723902612fea38afc0d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 13 May 2026 09:48:58 +0100 Subject: [PATCH 30/32] Python: Harden HttpPlugin request validation (#13969) Improve input validation and request handling in the Python HttpPlugin. ### Changes - Add explicit opt-in for unrestricted domain access - Tighten URL validation logic - Improve redirect handling when domain restrictions are configured - Add regression tests --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core_plugins/http_plugin.py | 80 +++++-- .../unit/core_plugins/test_http_plugin.py | 201 ++++++++++++++++-- 2 files changed, 251 insertions(+), 30 deletions(-) diff --git a/python/semantic_kernel/core_plugins/http_plugin.py b/python/semantic_kernel/core_plugins/http_plugin.py index 40269a5c49c8..8a554410fb44 100644 --- a/python/semantic_kernel/core_plugins/http_plugin.py +++ b/python/semantic_kernel/core_plugins/http_plugin.py @@ -15,23 +15,48 @@ class HttpPlugin(KernelBaseModel): """A plugin that provides HTTP functionality. Usage: - kernel.add_plugin(HttpPlugin(), "http") - - # With allowed domains for security: + # With allowed domains (recommended): kernel.add_plugin(HttpPlugin(allowed_domains=["example.com", "api.example.com"]), "http") + # Explicitly allow all domains (opt-in, less secure): + kernel.add_plugin(HttpPlugin(allow_all_domains=True), "http") + Examples: {{http.getAsync $url}} {{http.postAsync $url}} {{http.putAsync $url}} {{http.deleteAsync $url}} + + Security: + - By default, all requests are blocked unless ``allowed_domains`` is provided + or ``allow_all_domains`` is set to True. + - When ``allowed_domains`` is set and ``allow_all_domains`` is False, HTTP + redirects are disabled to prevent redirect-based domain bypass (SSRF). + - When ``allow_all_domains`` is True, redirects are allowed regardless of + whether ``allowed_domains`` is also set. + - Only ``http`` and ``https`` URL schemes are permitted. """ allowed_domains: set[str] | None = None - """List of allowed domains to send requests to. If None, all domains are allowed.""" + """Set of allowed domains to send requests to.""" + + allow_all_domains: bool = False + """When True, requests to any domain are allowed. Must be explicitly set.""" + + _ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"}) + + @property + def _allow_redirects(self) -> bool: + """Whether HTTP redirects should be followed. + + Redirects are only allowed when ``allow_all_domains`` is True. + When domain restrictions are configured, redirects are disabled + to prevent redirect-based SSRF bypass. + """ + return self.allow_all_domains def _is_uri_allowed(self, url: str) -> bool: - """Check if the URL's host is in the allowed domains list. + """Check if the URL's host and scheme are permitted. Args: url: The URL to check. @@ -39,25 +64,36 @@ def _is_uri_allowed(self, url: str) -> bool: Returns: True if the URL is allowed, False otherwise. """ - if self.allowed_domains is None: - return True - parsed = urlparse(url) + + # Validate scheme + if parsed.scheme.lower() not in self._ALLOWED_SCHEMES: + return False + host = parsed.hostname - if host is None: + if not host: return False - # Case-insensitive comparison - return host.lower() in {domain.lower() for domain in self.allowed_domains} + # If allow_all_domains is set, skip domain check + if self.allow_all_domains: + return True + + # If allowed_domains is set, check against it + if self.allowed_domains is not None: + return host.lower() in {domain.lower() for domain in self.allowed_domains} + + # Default: deny all + return False def _validate_url(self, url: str) -> None: - """Validate the URL, checking if it's not empty and is in the allowed domains. + """Validate the URL, checking scheme, emptiness, and allowed domains. Args: url: The URL to validate. Raises: - FunctionExecutionException: If the URL is empty or not in the allowed domains. + FunctionExecutionException: If the URL is empty, uses a disallowed scheme, + or targets a domain that is not allowed. """ if not url: raise FunctionExecutionException("url cannot be `None` or empty") @@ -77,7 +113,10 @@ async def get(self, url: Annotated[str, "The URL to send the request to."]) -> s """ self._validate_url(url) - async with aiohttp.ClientSession() as session, session.get(url, raise_for_status=True) as response: + async with ( + aiohttp.ClientSession() as session, + session.get(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response, + ): return await response.text() @kernel_function(description="Makes a POST request to a uri", name="postAsync") @@ -100,7 +139,9 @@ async def post( data = json.dumps(body) if body is not None else None async with ( aiohttp.ClientSession() as session, - session.post(url, headers=headers, data=data, raise_for_status=True) as response, + session.post( + url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects + ) as response, ): return await response.text() @@ -125,7 +166,9 @@ async def put( data = json.dumps(body) if body is not None else None async with ( aiohttp.ClientSession() as session, - session.put(url, headers=headers, data=data, raise_for_status=True) as response, + session.put( + url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects + ) as response, ): return await response.text() @@ -141,5 +184,8 @@ async def delete(self, url: Annotated[str, "The URI to send the request to."]) - """ self._validate_url(url) - async with aiohttp.ClientSession() as session, session.delete(url, raise_for_status=True) as response: + async with ( + aiohttp.ClientSession() as session, + session.delete(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response, + ): return await response.text() diff --git a/python/tests/unit/core_plugins/test_http_plugin.py b/python/tests/unit/core_plugins/test_http_plugin.py index 4472211c76c7..216967ffaee9 100644 --- a/python/tests/unit/core_plugins/test_http_plugin.py +++ b/python/tests/unit/core_plugins/test_http_plugin.py @@ -11,7 +11,7 @@ async def test_it_can_be_instantiated(): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) assert plugin is not None @@ -23,7 +23,7 @@ async def test_it_can_be_instantiated_with_allowed_domains(): async def test_it_can_be_imported(): kernel = Kernel() - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) kernel.add_plugin(plugin, "http") assert kernel.get_plugin(plugin_name="http") is not None assert kernel.get_plugin(plugin_name="http").name == "http" @@ -36,20 +36,20 @@ async def test_get(mock_get): mock_get.return_value.__aenter__.return_value.text.return_value = "Hello" mock_get.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) response = await plugin.get("https://example.org/get") assert response == "Hello" @pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) async def test_fail_no_url(method): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) with pytest.raises(FunctionExecutionException): await getattr(plugin, method)(url="") async def test_get_none_url(): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) with pytest.raises(FunctionExecutionException): await plugin.get(None) @@ -59,7 +59,7 @@ async def test_post(mock_post): mock_post.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_post.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/post", body="{message: 'Hello, world!'}") response = await plugin.post(**arguments) assert response == "Hello World !" @@ -70,7 +70,7 @@ async def test_post_nobody(mock_post): mock_post.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_post.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/post") response = await plugin.post(**arguments) assert response == "Hello World !" @@ -81,7 +81,7 @@ async def test_put(mock_put): mock_put.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_put.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/put", body="{message: 'Hello, world!'}") response = await plugin.put(**arguments) assert response == "Hello World !" @@ -92,7 +92,7 @@ async def test_put_nobody(mock_put): mock_put.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_put.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/put") response = await plugin.put(**arguments) assert response == "Hello World !" @@ -103,7 +103,7 @@ async def test_delete(mock_delete): mock_delete.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_delete.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/delete") response = await plugin.delete(**arguments) assert response == "Hello World !" @@ -178,9 +178,9 @@ async def test_allowed_domains_case_insensitive(): assert plugin._is_uri_allowed("https://Example.Com/path") is True -async def test_allowed_domains_none_allows_all(): - """Test that when allowed_domains is None, all domains are allowed.""" - plugin = HttpPlugin() # allowed_domains defaults to None +async def test_allow_all_domains_allows_all(): + """Test that when allow_all_domains is True, all domains are allowed.""" + plugin = HttpPlugin(allow_all_domains=True) assert plugin._is_uri_allowed("https://any-domain.com/path") is True assert plugin._is_uri_allowed("https://another-domain.org/path") is True @@ -214,3 +214,178 @@ async def test_allowed_domains_exact_subdomain_match(): assert plugin._is_uri_allowed("https://sub.example.com/path") is True assert plugin._is_uri_allowed("https://example.com/path") is False assert plugin._is_uri_allowed("https://other.example.com/path") is False + + +# Security regression tests + + +async def test_default_constructor_denies_all(): + """Test that default HttpPlugin() denies all requests (issue 115285).""" + plugin = HttpPlugin() + assert plugin._is_uri_allowed("https://example.com/path") is False + assert plugin._is_uri_allowed("https://any-domain.com/path") is False + + +@pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) +async def test_default_constructor_blocks_requests(method): + """Test that default HttpPlugin() blocks all HTTP methods (issue 115285).""" + plugin = HttpPlugin() + with pytest.raises(FunctionExecutionException, match="Sending requests to the provided location is not allowed"): + if method in ["post", "put"]: + await getattr(plugin, method)(url="https://example.com/path", body={"key": "value"}) + else: + await getattr(plugin, method)(url="https://example.com/path") + + +@patch("aiohttp.ClientSession.get") +async def test_allow_all_domains_flag(mock_get): + """Test that allow_all_domains=True permits requests to any domain.""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + response = await plugin.get("https://any-domain.com/path") + assert response == "OK" + + +@patch("aiohttp.ClientSession.get") +async def test_redirects_disabled_with_allowed_domains(mock_get): + """Test that redirects are disabled when allowed_domains is set (issue 115048).""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.get("https://example.com/path") + + _, kwargs = mock_get.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.post") +async def test_redirects_disabled_for_post_with_allowed_domains(mock_post): + """Test that redirects are disabled for POST when allowed_domains is set.""" + mock_post.return_value.__aenter__.return_value.text.return_value = "OK" + mock_post.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.post("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_post.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.put") +async def test_redirects_disabled_for_put_with_allowed_domains(mock_put): + """Test that redirects are disabled for PUT when allowed_domains is set.""" + mock_put.return_value.__aenter__.return_value.text.return_value = "OK" + mock_put.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.put("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_put.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.delete") +async def test_redirects_disabled_for_delete_with_allowed_domains(mock_delete): + """Test that redirects are disabled for DELETE when allowed_domains is set.""" + mock_delete.return_value.__aenter__.return_value.text.return_value = "OK" + mock_delete.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.delete("https://example.com/path") + + _, kwargs = mock_delete.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.get") +async def test_redirects_allowed_with_allow_all_domains(mock_get): + """Test that redirects are still allowed when allow_all_domains is True.""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.get("https://example.com/path") + + _, kwargs = mock_get.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.post") +async def test_redirects_allowed_for_post_with_allow_all_domains(mock_post): + """Test that redirects are allowed for POST when allow_all_domains is True.""" + mock_post.return_value.__aenter__.return_value.text.return_value = "OK" + mock_post.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.post("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_post.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.put") +async def test_redirects_allowed_for_put_with_allow_all_domains(mock_put): + """Test that redirects are allowed for PUT when allow_all_domains is True.""" + mock_put.return_value.__aenter__.return_value.text.return_value = "OK" + mock_put.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.put("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_put.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.delete") +async def test_redirects_allowed_for_delete_with_allow_all_domains(mock_delete): + """Test that redirects are allowed for DELETE when allow_all_domains is True.""" + mock_delete.return_value.__aenter__.return_value.text.return_value = "OK" + mock_delete.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.delete("https://example.com/path") + + _, kwargs = mock_delete.call_args + assert kwargs["allow_redirects"] is True + + +@pytest.mark.parametrize("scheme", ["file", "ftp", "gopher", "data"]) +async def test_disallowed_schemes_blocked(scheme): + """Test that non-HTTP schemes are blocked.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed(f"{scheme}://example.com/path") is False + + +@pytest.mark.parametrize("scheme", ["file", "ftp", "gopher"]) +@pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) +async def test_disallowed_schemes_blocked_all_methods(scheme, method): + """Test that non-HTTP schemes are blocked for all HTTP methods.""" + plugin = HttpPlugin(allow_all_domains=True) + with pytest.raises(FunctionExecutionException, match="Sending requests to the provided location is not allowed"): + if method in ["post", "put"]: + await getattr(plugin, method)(url=f"{scheme}://example.com/path", body={"key": "value"}) + else: + await getattr(plugin, method)(url=f"{scheme}://example.com/path") + + +async def test_http_scheme_allowed(): + """Test that both http and https schemes are allowed.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed("http://example.com/path") is True + assert plugin._is_uri_allowed("https://example.com/path") is True + + +async def test_empty_hostname_rejected(): + """Test that URLs with empty hostnames are rejected.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed("http://") is False + assert plugin._is_uri_allowed("https://") is False + + +async def test_allow_all_domains_with_allowed_domains_allows_redirects(): + """Test that redirects are allowed when both allow_all_domains and allowed_domains are set.""" + plugin = HttpPlugin(allowed_domains={"example.com"}, allow_all_domains=True) + assert plugin._is_uri_allowed("https://any-domain.com/path") is True From a6c68af59492b851b3c33c98eb4734cdb9a0f620 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 14 May 2026 09:46:19 +0900 Subject: [PATCH 31/32] Python: Improvements for MCP (#14003) ### Motivation and Context Improvements for MCP tool calls by adding an optional sampling callback. ### Description Improvements for MCP tool calls by adding an optional sampling callback. ### Contribution Checklist - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/semantic_kernel/connectors/mcp.py | 95 ++++++++++++++- python/tests/unit/connectors/mcp/test_mcp.py | 115 +++++++++++++++++++ 2 files changed, 205 insertions(+), 5 deletions(-) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 5f31886b28fa..6d7f8d2e182d 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -6,7 +6,7 @@ import re import sys from abc import abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, _AsyncGeneratorContextManager from datetime import timedelta from functools import partial @@ -59,6 +59,8 @@ # region: Helpers +SamplingConsentCallback = Callable[[str, types.CreateMessageRequestParams], Awaitable[bool]] + LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = { "debug": logging.DEBUG, "info": logging.INFO, @@ -243,8 +245,23 @@ def __init__( session: ClientSession | None = None, kernel: Kernel | None = None, request_timeout: int | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, ) -> None: - """Initialize the MCP Plugin Base.""" + """Initialize the MCP Plugin Base. + + Args: + name: The name of the plugin. + description: The description of the plugin. + load_tools: Whether to load tools from the MCP server. + load_prompts: Whether to load prompts from the MCP server. + session: The session to use for the MCP connection. + kernel: The kernel instance with one or more Chat Completion clients. + request_timeout: The default timeout used for all requests. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. + """ self.name = name self.description = description self.load_tools_flag = load_tools @@ -253,6 +270,9 @@ def __init__( self.session = session self.kernel = kernel or None self.request_timeout = request_timeout + self.sampling_consent_callback = sampling_consent_callback + self._sampling_auto_approved_warning_logged = False + self._mcp_reserved_attribute_names: set[str] | None = None self._current_task: asyncio.Task | None = None self._stop_event: asyncio.Event | None = None @@ -361,9 +381,24 @@ async def sampling_callback( This function is called when the MCP server needs to get a message completed. - This is a simple version of this function, it can be overridden to allow more complex sampling. - It get's added to the session at initialization time, so overriding it is the best way to do this. + If a sampling consent callback is configured, it is called before forwarding the request to the configured + chat completion service. Returning False denies the request. If no callback is configured, requests are + auto-approved and a warning is logged. """ + if self.sampling_consent_callback is None: + if not self._sampling_auto_approved_warning_logged: + logger.warning( + "MCP sampling request for plugin '%s' was auto-approved because no sampling consent callback " + "was configured.", + self.name, + ) + self._sampling_auto_approved_warning_logged = True + elif not await self._is_sampling_approved(params): + return types.ErrorData( + code=types.INTERNAL_ERROR, + message="Sampling denied by policy.", + ) + if not self.kernel or not self.kernel.services: return types.ErrorData( code=types.INTERNAL_ERROR, @@ -431,6 +466,15 @@ async def sampling_callback( model=service.ai_model_id, ) + async def _is_sampling_approved(self, params: types.CreateMessageRequestParams) -> bool: + if self.sampling_consent_callback is None: + return True + try: + return await self.sampling_consent_callback(self.name, params) + except Exception: + logger.exception("MCP sampling consent callback failed for plugin '%s'.", self.name) + return False + async def logging_callback(self, params: types.LoggingMessageNotificationParams) -> None: """Callback function for logging. @@ -464,6 +508,19 @@ async def message_handler( case "notifications/prompts/list_changed": await self.load_prompts() + def _has_mcp_function_name_conflict(self, item_type: str, remote_name: str, local_name: str) -> bool: + if self._mcp_reserved_attribute_names is None: + self._mcp_reserved_attribute_names = set(dir(self)) + if local_name not in self._mcp_reserved_attribute_names: + return False + logger.warning( + "Skipping MCP %s '%s' because normalized name '%s' conflicts with an existing plugin attribute.", + item_type, + remote_name, + local_name, + ) + return True + async def load_prompts(self): """Load prompts from the MCP server.""" try: @@ -472,6 +529,8 @@ async def load_prompts(self): prompt_list = None for prompt in prompt_list.prompts if prompt_list else []: local_name = _normalize_mcp_name(prompt.name) + if self._has_mcp_function_name_conflict("prompt", prompt.name, local_name): + continue func = kernel_function(name=local_name, description=prompt.description)( partial(self.get_prompt, prompt.name) ) @@ -484,9 +543,11 @@ async def load_tools(self): tool_list = await self.session.list_tools() except Exception: tool_list = None - # Create methods with the kernel_function decorator for each tool + # Create methods with the kernel_function decorator for each tool for tool in tool_list.tools if tool_list else []: local_name = _normalize_mcp_name(tool.name) + if self._has_mcp_function_name_conflict("tool", tool.name, local_name): + continue func = kernel_function(name=local_name, description=tool.description)(partial(self.call_tool, tool.name)) func.__kernel_function_parameters__ = _get_parameter_dicts_from_mcp_tool(tool) setattr(self, local_name, func) @@ -558,6 +619,7 @@ def __init__( env: dict[str, str] | None = None, encoding: str | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP stdio plugin. @@ -579,6 +641,10 @@ def __init__( env: The environment variables to set for the command. encoding: The encoding to use for the command output. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the stdio client. """ @@ -590,6 +656,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.command = command self.args = args or [] @@ -628,6 +695,7 @@ def __init__( timeout: float | None = None, sse_read_timeout: float | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP sse plugin. @@ -650,6 +718,10 @@ def __init__( timeout: The timeout for the request. sse_read_timeout: The timeout for reading from the SSE stream. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the sse client. """ @@ -661,6 +733,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.url = url self.headers = headers or {} @@ -702,6 +775,7 @@ def __init__( sse_read_timeout: float | None = None, terminate_on_close: bool | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP streamable http plugin. @@ -725,6 +799,10 @@ def __init__( sse_read_timeout: The timeout for reading from the SSE stream. terminate_on_close: Close the transport when the MCP client is terminated. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the sse client. """ super().__init__( @@ -735,6 +813,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.url = url self.headers = headers or {} @@ -775,6 +854,7 @@ def __init__( session: ClientSession | None = None, description: str | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP websocket plugin. @@ -794,6 +874,10 @@ def __init__( session: The session to use for the MCP connection. description: The description of the plugin. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the websocket client. """ @@ -805,6 +889,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.url = url self._client_kwargs = kwargs diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 55ca71313574..dc8ea38330d3 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import logging import re from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch @@ -89,6 +90,120 @@ async def test_mcp_plugin_session_initialized(plugin_class, plugin_args): assert not mock_session.initialize.called +async def test_mcp_sampling_denied_by_consent_callback(): + sampling_consent_callback = AsyncMock(return_value=False) + plugin = MCPSsePlugin( + name="TestMCPPlugin", + url="http://localhost:8080/sse", + sampling_consent_callback=sampling_consent_callback, + ) + params = types.CreateMessageRequestParams( + messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], + systemPrompt="server instructions", + maxTokens=100, + ) + + result = await plugin.sampling_callback(MagicMock(), params) + + sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params) + assert isinstance(result, types.ErrorData) + assert result.message == "Sampling denied by policy." + + +async def test_mcp_sampling_consent_callback_error_denies_request(caplog): + sampling_consent_callback = AsyncMock(side_effect=RuntimeError("policy failure")) + plugin = MCPSsePlugin( + name="TestMCPPlugin", + url="http://localhost:8080/sse", + sampling_consent_callback=sampling_consent_callback, + ) + params = types.CreateMessageRequestParams( + messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], + systemPrompt="server instructions", + maxTokens=100, + ) + + with caplog.at_level(logging.ERROR, logger="semantic_kernel.connectors.mcp"): + result = await plugin.sampling_callback(MagicMock(), params) + + sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params) + assert isinstance(result, types.ErrorData) + assert result.message == "Sampling denied by policy." + assert "MCP sampling consent callback failed" in caplog.text + + +async def test_mcp_sampling_without_consent_callback_logs_auto_approve_warning(caplog): + plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse") + params = types.CreateMessageRequestParams( + messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], + systemPrompt="server instructions", + maxTokens=100, + ) + + with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): + result = await plugin.sampling_callback(MagicMock(), params) + + assert isinstance(result, types.ErrorData) + assert "auto-approved because no sampling consent callback was configured" in caplog.text + + +async def test_mcp_tool_and_prompt_names_do_not_shadow_plugin_attributes(): + kernel = MagicMock() + plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse", kernel=kernel) + session = AsyncMock(spec=ClientSession) + session.list_tools.return_value = ListToolsResult( + tools=[ + Tool(name="kernel", description="reserved", inputSchema={}), + Tool(name="safe_tool", description="safe", inputSchema={}), + ] + ) + session.list_prompts.return_value = types.ListPromptsResult( + prompts=[ + types.Prompt(name="session", description="reserved", arguments=[]), + types.Prompt(name="safe_prompt", description="safe", arguments=[]), + ] + ) + plugin.session = session + + await plugin.load_tools() + + assert plugin.kernel is kernel + assert hasattr(plugin, "safe_tool") + + await plugin.load_prompts() + + assert plugin.session is session + assert hasattr(plugin, "safe_prompt") + + +async def test_mcp_tool_and_prompt_names_can_reload_existing_mcp_functions(): + plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse") + session = AsyncMock(spec=ClientSession) + session.list_tools.side_effect = [ + ListToolsResult(tools=[Tool(name="safe_tool", description="first tool", inputSchema={})]), + ListToolsResult(tools=[Tool(name="safe_tool", description="second tool", inputSchema={})]), + ] + session.list_prompts.side_effect = [ + types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="first prompt", arguments=[])]), + types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="second prompt", arguments=[])]), + ] + plugin.session = session + + await plugin.load_tools() + first_tool = plugin.safe_tool + await plugin.load_tools() + + assert plugin.safe_tool is not first_tool + assert plugin.safe_tool.__kernel_function_description__ == "second tool" + + await plugin.load_prompts() + first_prompt = plugin.safe_prompt + await plugin.load_prompts() + + assert plugin.safe_prompt is not first_prompt + assert plugin.safe_prompt.__kernel_function_description__ == "second prompt" + + async def test_mcp_plugin_failed_get_session(): with ( patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client, From 2fb749e4bb0da637ca26be0442930a5ad84f54f3 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 14 May 2026 11:08:02 +0900 Subject: [PATCH 32/32] Python: Bump Python pkg version to 1.42.0 for a release. (#14007) ### Motivation and Context Bump Python pkg version to 1.42.0 for a release. ### Description Bump Python pkg version to 1.42.0 for a release. ### Contribution Checklist - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone :smile: --- python/semantic_kernel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/semantic_kernel/__init__.py b/python/semantic_kernel/__init__.py index 79b0f3f3ccf8..090bd7d0cd47 100644 --- a/python/semantic_kernel/__init__.py +++ b/python/semantic_kernel/__init__.py @@ -2,7 +2,7 @@ from semantic_kernel.kernel import Kernel -__version__ = "1.41.3" +__version__ = "1.42.0" DEFAULT_RC_VERSION = f"{__version__}-rc9"