From 38a5ae25fb4e19297af0da673a09748bd8899a80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:12:42 +0000 Subject: [PATCH 01/42] Build(deps-dev): Bump prek in /python/packages/lab in the basics group (#8612) Bumps the basics group in /python/packages/lab with 1 update: [prek](https://github.com/j178/prek). Updates `prek` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.5.2...v0.5.3) --- updated-dependencies: - dependency-name: prek dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: basics ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/packages/lab/pyproject.toml | 2 +- python/packages/lab/uv.lock | 923 ++++++++++++++--------------- 2 files changed, 461 insertions(+), 464 deletions(-) diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 02b55613b0e..d062d5e6f0b 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -70,7 +70,7 @@ dev = [ "rich>=13.7.1,<16.0.0", "tomli==2.4.1", "tomli-w==1.2.0", - "prek==0.5.2", + "prek==0.5.3", ] # tau2 is an executable optional feature fetched from source because it is not available on PyPI. tau2 = [ diff --git a/python/packages/lab/uv.lock b/python/packages/lab/uv.lock index 3f26d38382b..0873e8d2363 100644 --- a/python/packages/lab/uv.lock +++ b/python/packages/lab/uv.lock @@ -27,9 +27,6 @@ supported-markers = [ "sys_platform == 'win32'", ] -[options] -prerelease-mode = "if-necessary" - [manifest] constraints = [ { name = "fastapi-sso", specifier = ">=0.19.0" }, @@ -55,11 +52,11 @@ name = "agent-framework-core" version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msgspec" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/6e/e0d661f1770bf3a6d70248c5964e684d2bb648b253d53f3dca315f393535/agent_framework_core-1.18.0.tar.gz", hash = "sha256:ef84e1ec570036b81a93a9e924bc4281380f6240ab9bddb8a2db7669282032b0", size = 652594, upload-time = "2026-09-10T09:29:19.938Z" } wheels = [ @@ -71,52 +68,52 @@ name = "agent-framework-lab" version = "1.0.0b260910" source = { editable = "." } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, ] [package.optional-dependencies] gaia = [ - { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyarrow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "huggingface-hub" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "tqdm" }, ] lightning = [ - { name = "agentlightning", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agentlightning" }, ] math = [ - { name = "sympy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sympy" }, ] tau2 = [ - { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.2.6", 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 = "numpy", version = "2.4.6", 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 = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "loguru" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic" }, + { name = "tiktoken" }, ] [package.dev-dependencies] dev = [ - { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-openai" }, + { name = "mypy" }, + { name = "poethepoet" }, + { name = "prek" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "rich" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "uv" }, ] tau2 = [ - { name = "tau2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tau2" }, ] [package.metadata] @@ -143,7 +140,7 @@ dev = [ { name = "agent-framework-openai", specifier = ">=1.14.2,<2" }, { name = "mypy", specifier = "==2.3.1" }, { name = "poethepoet", specifier = "==0.48.0" }, - { name = "prek", specifier = "==0.5.2" }, + { name = "prek", specifier = "==0.5.3" }, { name = "pyright", specifier = "==1.1.414" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, @@ -161,8 +158,8 @@ name = "agent-framework-openai" version = "1.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "openai" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/7c/6ef5b4b2e5fd8ab4750c41edaca6755694a7574e4b884c3cbdffa0a1846a/agent_framework_openai-1.14.3.tar.gz", hash = "sha256:187587bb1026f0989bbfcbb1f94e058103cbfc5066006d4b82d4c00f94469a53", size = 63541, upload-time = "2026-09-10T09:29:28.72Z" } wheels = [ @@ -174,26 +171,26 @@ name = "agentlightning" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agentops", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiologic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gpustat", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "graphviz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm", extra = ["proxy"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "portpicker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "setproctitle", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn-worker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agentops" }, + { name = "aiohttp" }, + { name = "aiologic" }, + { name = "fastapi" }, + { name = "flask" }, + { name = "gpustat" }, + { name = "graphviz" }, + { name = "gunicorn" }, + { name = "litellm", extra = ["proxy"] }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "portpicker" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "setproctitle" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn-worker" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/8f/1bed06f70d52ba4b2ed698605fa955a82ee5aaec3addee5a21e3fd7cd0cb/agentlightning-0.3.0.tar.gz", hash = "sha256:35cd702bce54ff7c8c097d8e73aaf688c6649e4ee81e6e5e0379000465b75d43", size = 1345454, upload-time = "2025-12-24T01:49:31.24Z" } wheels = [ @@ -205,20 +202,20 @@ name = "agentops" version = "0.4.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ordered-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "termcolor", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "ordered-set" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "termcolor" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/023fe976169c57b1edd71f4c08d6dedaf66814f5b25ecf59b3a8540311ab/agentops-0.4.21.tar.gz", hash = "sha256:47759c6dfd6ea58bad2f7764257e4778cb2e34ae180cef642f60f56adced6510", size = 430861, upload-time = "2025-08-29T06:36:55.323Z" } wheels = [ @@ -239,15 +236,15 @@ name = "aiohttp" version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiosignal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "async-timeout", 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 = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ @@ -372,9 +369,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -386,8 +383,8 @@ name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -426,9 +423,9 @@ name = "anyio" version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", 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 = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.15' and sys_platform == 'darwin') or (python_full_version < '3.15' and sys_platform == 'linux') or (python_full_version < '3.15' and sys_platform == 'win32')" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ @@ -449,7 +446,7 @@ name = "apscheduler" version = "3.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tzlocal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" } wheels = [ @@ -543,8 +540,8 @@ name = "azure-core" version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } wheels = [ @@ -556,11 +553,11 @@ name = "azure-identity" version = "1.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } wheels = [ @@ -572,10 +569,10 @@ name = "azure-storage-blob" version = "12.30.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/7e/834d7bfcf999ab89d1bd3a5d235ece6824686da2f3d315e2162c613fe43d/azure_storage_blob-12.30.1.tar.gz", hash = "sha256:7a24f978c51d56a0375beebffcbe8453e59ae390d2695705848edc75083e4184", size = 624787, upload-time = "2026-08-27T19:12:54.967Z" } wheels = [ @@ -605,8 +602,8 @@ name = "blessed" version = "1.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinxed", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wcwidth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinxed" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/60/0a/5ad035b1fb3ce21f8d2892f697c17b9ff7b9f5905aeddab1ce438b7fd48c/blessed-1.49.0.tar.gz", hash = "sha256:a1c5e15c895898b976d85e6c7278496b325bbb4e656794413bf5c1648c9c12c9", size = 14055928, upload-time = "2026-08-31T15:04:20.584Z" } wheels = [ @@ -627,9 +624,9 @@ name = "boto3" version = "1.43.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/0b/53f833efea7331aab106a65f1156e0035beed9bacbfcb309b128204468d4/boto3-1.43.90.tar.gz", hash = "sha256:4b669742d5b45b8fd20ca50ac414a4e4cf995ebb8f280d21be28676e71c97594", size = 112689, upload-time = "2026-09-08T19:22:30.875Z" } wheels = [ @@ -641,9 +638,9 @@ name = "botocore" version = "1.43.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6b/3e/29872261075d878581a31e9e98c512c0b85322a36e6d6784b9f0694a918d/botocore-1.43.90.tar.gz", hash = "sha256:a139ed601e8b8fb1d730022355fe2b284b8c15cfe9ac0f100254a35d2273e1d3", size = 16081908, upload-time = "2026-09-08T19:22:25.645Z" } wheels = [ @@ -783,7 +780,7 @@ name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "(implementation_name != 'PyPy' and sys_platform == 'darwin') or (implementation_name != 'PyPy' and sys_platform == 'linux') or (implementation_name != 'PyPy' and sys_platform == 'win32')" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -1084,7 +1081,7 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", 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 = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -1168,8 +1165,8 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", 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 = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1376,7 +1373,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "(python_full_version <= '3.11' and sys_platform == 'darwin') or (python_full_version <= '3.11' and sys_platform == 'linux') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -1384,7 +1381,7 @@ name = "croniter" version = "6.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } wheels = [ @@ -1396,8 +1393,8 @@ name = "cryptography" version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, - { name = "typing-extensions", 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 = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ @@ -1462,8 +1459,8 @@ name = "deepdiff" version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachebox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orderly-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cachebox" }, + { name = "orderly-set" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/6a4a5aaf38535eb332c2856aa08e73ed7c549d0851b1215401af0a2db1a7/deepdiff-9.1.0.tar.gz", hash = "sha256:07e9e366fab4297755153c4eab795ad4ef3cbd0d51660e847f5751c6bd727687", size = 382149, upload-time = "2026-05-15T20:18:05.751Z" } wheels = [ @@ -1502,8 +1499,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "dnspython" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -1515,7 +1512,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", 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 = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1527,7 +1524,7 @@ name = "expression" version = "5.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/da/0a12e324567a068745284d7247ffe53547399bae633ead3f6ec5d2f7a030/expression-5.7.0.tar.gz", hash = "sha256:4c5ea4247f871b8724ad580911ad73c1550fc653bb669daf2d49e4b645cc4770", size = 213081, upload-time = "2026-08-15T17:14:56.507Z" } wheels = [ @@ -1539,11 +1536,11 @@ name = "fastapi" version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ @@ -1555,11 +1552,11 @@ name = "fastapi-sso" version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", extra = ["email"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "oauthlib" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyjwt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/47/e6ecdd15874d7b9ac3202979396fedfd964e62d005d85eaf6dec2af1da95/fastapi_sso-0.22.0.tar.gz", hash = "sha256:94a71869097fba7c1d36a24939b9fe31cc59ba1c31d25ac661a35f6c810968ea", size = 19522, upload-time = "2026-08-31T10:15:29.925Z" } wheels = [ @@ -1643,12 +1640,12 @@ name = "flask" version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blinker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "itsdangerous", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ @@ -1854,9 +1851,9 @@ name = "fs" version = "2.4.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "appdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "setuptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "appdirs" }, + { name = "setuptools" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" } wheels = [ @@ -1877,7 +1874,7 @@ name = "googleapis-common-protos" version = "1.75.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } wheels = [ @@ -1889,9 +1886,9 @@ name = "gpustat" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blessed", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "nvidia-ml-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "blessed" }, + { name = "nvidia-ml-py" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/c4/46d005aec3bf911cb030467d91e062a5386ff4a03e51874424cacc0f60c1/gpustat-1.1.1.tar.gz", hash = "sha256:c18d3ed5518fc16300c42d694debc70aebb3be55cae91f1db64d63b5fa8af9d8", size = 98052, upload-time = "2023-08-22T19:39:06.062Z" } @@ -1900,7 +1897,7 @@ name = "granian" version = "2.8.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/74/9903bd396a3ea5e40163ce4946df974bfb9f7280834b1c59d727df65867c/granian-2.8.2.tar.gz", hash = "sha256:466a23e8cb44d4b407fa3db0f37aeb45ae722cad4d4b3701d6fa6b13ca54b1ef", size = 129359, upload-time = "2026-08-23T17:40:23.248Z" } wheels = [ @@ -2008,7 +2005,7 @@ name = "grpcio" version = "1.83.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" } wheels = [ @@ -2069,7 +2066,7 @@ name = "gunicorn" version = "23.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } wheels = [ @@ -2206,8 +2203,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -2269,10 +2266,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpcore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -2293,15 +2290,15 @@ name = "huggingface-hub" version = "1.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fsspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hf-xet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'arm64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'arm64' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/f0/61159db90b5cd275d55516fe27920828e7d3be4053fdbdb27c3f70e5f1ef/huggingface_hub-1.31.0.tar.gz", hash = "sha256:f8e9e710a210613fa5d0f26bba6da05ef4aef9fba5a0f23f508f5ac4d08b6f90", size = 968039, upload-time = "2026-09-10T10:27:22.724Z" } wheels = [ @@ -2322,7 +2319,7 @@ name = "importlib-metadata" version = "8.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } wheels = [ @@ -2343,8 +2340,8 @@ name = "inquirerpy" version = "0.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pfzy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prompt-toolkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pfzy" }, + { name = "prompt-toolkit" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } wheels = [ @@ -2374,7 +2371,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -2506,7 +2503,7 @@ name = "joblib" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cloudpickle", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cloudpickle" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/1d/537ab090f302b838943a1b56497dd53059b9a9b46a074936470173a2e207/joblib-1.6.0.tar.gz", hash = "sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03", size = 327903, upload-time = "2026-08-31T09:39:04.122Z" } wheels = [ @@ -2518,11 +2515,11 @@ name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema-specifications", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rpds-py", version = "0.30.0", 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 = "rpds-py", version = "2026.6.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 = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -2534,7 +2531,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "referencing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -2694,15 +2691,15 @@ name = "langfuse" version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/51/ed5569bc2dcc8fe767e3b315207a7511ad23d00d811f02bbf0d6f80bf906/langfuse-4.15.1.tar.gz", hash = "sha256:70cb47529a6ba78383c4f2a197eb3d3ab9d39529c9e6b568d392150c1f40dbb9", size = 432353, upload-time = "2026-08-28T07:55:15.894Z" } wheels = [ @@ -2844,20 +2841,20 @@ name = "litellm" version = "1.100.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastuuid", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "importlib-metadata", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "boto3" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/03/ce/1e1ce2558f65244057c0c40c60ade4dfa3767da3522bf1dd8679507ad7ba/litellm-1.100.0.tar.gz", hash = "sha256:ece94e817a453a5b3a9517c03547c428d501cea719edb728c1b260e53f78ea35", size = 17287857, upload-time = "2026-09-06T00:22:46.023Z" } wheels = [ @@ -2872,37 +2869,37 @@ wheels = [ [package.optional-dependencies] proxy = [ - { name = "apscheduler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-storage-blob", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "expression", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi-sso", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "granian", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hiredis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "inquirerpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm-enterprise", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm-proxy-extras", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "polars", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pynacl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyroscope-io", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "restrictedpython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rq", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "soundfile", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvloop", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "apscheduler" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, + { name = "backoff" }, + { name = "boto3" }, + { name = "cryptography" }, + { name = "expression" }, + { name = "fastapi" }, + { name = "fastapi-sso" }, + { name = "granian" }, + { name = "gunicorn" }, + { name = "hiredis" }, + { name = "inquirerpy" }, + { name = "litellm-enterprise" }, + { name = "litellm-proxy-extras" }, + { name = "mcp", extra = ["ws"] }, + { name = "orjson" }, + { name = "polars" }, + { name = "pyjwt" }, + { name = "pynacl" }, + { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "restrictedpython" }, + { name = "rich" }, + { name = "rq" }, + { name = "soundfile" }, + { name = "starlette" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, + { name = "websockets" }, ] [[package]] @@ -2941,7 +2938,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -3043,15 +3040,15 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", 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 = "cycler", 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 = "fonttools", 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 = "kiwisolver", 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", version = "2.2.6", 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 = "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 = "pillow", 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 = "pyparsing", 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 = "python-dateutil", 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 = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -3133,16 +3130,16 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.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 = "cycler", 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 = "fonttools", 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 = "kiwisolver", 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", version = "2.4.6", 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 = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "packaging", marker = "(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 = "pillow", 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 = "pyparsing", 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 = "python-dateutil", 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 = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } wheels = [ @@ -3198,20 +3195,20 @@ name = "mcp" version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } wheels = [ @@ -3220,7 +3217,7 @@ wheels = [ [package.optional-dependencies] ws = [ - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websockets" }, ] [[package]] @@ -3246,9 +3243,9 @@ name = "msal" version = "1.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b8/1f/10f9d47a63d3a2e61b2c43e15bee6b95682aab827018f9a1b97a80787e25/msal-1.38.0.tar.gz", hash = "sha256:4f10ff1257bacfd1781f22e85bd2b8d43ad1b490f3b6aafd7906671cadedd464", size = 203411, upload-time = "2026-08-24T10:22:46.053Z" } wheels = [ @@ -3260,7 +3257,7 @@ name = "msal-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } wheels = [ @@ -3328,7 +3325,7 @@ name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", 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 = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ @@ -3466,12 +3463,12 @@ name = "mypy" version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ast-serialize", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, - { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } wheels = [ @@ -3816,14 +3813,14 @@ name = "openai" version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" } wheels = [ @@ -3835,7 +3832,7 @@ name = "opentelemetry-api" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } wheels = [ @@ -3847,8 +3844,8 @@ name = "opentelemetry-exporter-otlp" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106", size = 6090, upload-time = "2026-07-16T15:25:34.585Z" } wheels = [ @@ -3860,7 +3857,7 @@ name = "opentelemetry-exporter-otlp-proto-common" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-proto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } wheels = [ @@ -3872,13 +3869,13 @@ name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } wheels = [ @@ -3890,13 +3887,13 @@ name = "opentelemetry-exporter-otlp-proto-http" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } wheels = [ @@ -3908,10 +3905,10 @@ name = "opentelemetry-instrumentation" version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } wheels = [ @@ -3923,7 +3920,7 @@ name = "opentelemetry-proto" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } wheels = [ @@ -3935,9 +3932,9 @@ name = "opentelemetry-sdk" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } wheels = [ @@ -3949,8 +3946,8 @@ name = "opentelemetry-semantic-conventions" version = "0.65b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } wheels = [ @@ -4066,10 +4063,10 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", 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 = "python-dateutil", 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 = "pytz", 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 = "tzdata", 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", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -4144,10 +4141,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", 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 = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "python-dateutil", marker = "(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 = "tzdata", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -4320,8 +4317,8 @@ name = "plotly" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "narwhals" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/9e/8894e8eae20a5b2a44aa568b1d2d80291e8eea6ce7bc75cdc7a152aef0ac/plotly-7.0.0.tar.gz", hash = "sha256:08b21f1244a97e7a1a699833c4bb2678475aa108b3f1989886ed0b038ebfd849", size = 6218668, upload-time = "2026-08-25T17:47:29.088Z" } wheels = [ @@ -4342,9 +4339,9 @@ name = "poethepoet" version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pastel", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "pastel" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/92/93a4af9511b8c7c647874521d9e6c904266be98067c2ee1eb2e74520d208/poethepoet-0.48.0.tar.gz", hash = "sha256:a06f49d244fadfc2e2e7faa78b54e64a9694727e4ce1d50e08f23cea3ded74f1", size = 148679, upload-time = "2026-07-05T21:48:30.106Z" } wheels = [ @@ -4356,7 +4353,7 @@ name = "polars" version = "1.44.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "polars-runtime-32" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/15/e8541eefc22fbc7ca89bcb5112298a153729f73cfbc0cf6a668e509f975c/polars-1.44.2.tar.gz", hash = "sha256:86c8e26b6c2de8c8d344bb910b74dfc47b118ac3fe0f19b44909467990a0b281", size = 766272, upload-time = "2026-09-09T07:42:08.859Z" } wheels = [ @@ -4384,7 +4381,7 @@ name = "portpicker" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/d0/cda2fc582f09510c84cd6b7d7b9e22a02d4e45dbad2b2ef1c6edd7847e00/portpicker-1.6.0.tar.gz", hash = "sha256:bd507fd6f96f65ee02781f2e674e9dc6c99bbfa6e3c39992e3916204c9d431fa", size = 25676, upload-time = "2023-08-15T04:37:08.865Z" } wheels = [ @@ -4393,18 +4390,18 @@ wheels = [ [[package]] name = "prek" -version = "0.5.2" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/05/4402641f168dd862d27592cbd4c5fb171d119c15611e5dbef3df857e8932/prek-0.5.2.tar.gz", hash = "sha256:a90ea4eeba35c6081bad29775f6a49fcc33fb80372424fce64c7a9c5774e8204", size = 549442, upload-time = "2026-09-02T17:49:27.241Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/93/d1e5afc996b9fde04d71c37fd28bdd404e54daf1da6c76b883c5cdbdb411/prek-0.5.3.tar.gz", hash = "sha256:06d88bed9a5b2886cd3796957e4cecf05fa9b06724b625df31dbf0a48ff2330c", size = 551929, upload-time = "2026-09-13T08:38:37.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/25/cf9c6064fea82ba6f4801dc3be650cc86eae3d9951e2a6fb28b21cbfa156/prek-0.5.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:40d488a48de3f47f48bd4911ee5dec25e615f992b4cde821e9b50d40c615b39e", size = 5982622, upload-time = "2026-09-02T17:49:10.111Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d9/9772fd4d4023c34780d957f407d9437e2a16d6bef77e3da9fa4374cd75bb/prek-0.5.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0eb3b812fc13bc5c05cfc1a8b469f9665a02f845753c3759ad7aca50af707afb", size = 5525662, upload-time = "2026-09-02T17:49:12.336Z" }, - { url = "https://files.pythonhosted.org/packages/3b/19/be25a56ab243f2e5c9333c38bad33bddffec92ea8a25e34248eba6e00ac8/prek-0.5.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0219d1e694a962e3b3ae98f42f53065d2e2856719caee88578d24b35ea74cad", size = 5826109, upload-time = "2026-09-02T17:49:13.994Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bd/a4a5a3022915b8e1768df9b2d8744f3e7c4b3bb7cdbe697612cc96054169/prek-0.5.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0e22f11c1fb5df89acf5cbde78f6139edf2c30a205bea5a5d4a094366dcdbda", size = 6201473, upload-time = "2026-09-02T17:49:16.156Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fb/2e5f8adbee718f15333a113872104c4b3d25ecce423dd7117cfcd28a25fe/prek-0.5.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1d52de8428d5eef7c8c0fd64efd7d31210d13924fd36a02975d462675715aeb7", size = 5829166, upload-time = "2026-09-02T17:49:17.997Z" }, - { url = "https://files.pythonhosted.org/packages/44/71/c798d54741cc8f345b211e453dda9599546fb213c3593eb711cb6c710bbf/prek-0.5.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:86aebcd8ab831036fc3ac9b15275412403e21041da031e6c9f1547888843e5c6", size = 6318980, upload-time = "2026-09-02T17:49:21.145Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f8/4aefbf76dae091078655fbaf065c34fa900d43b39c2b88e80cbaa71b5efb/prek-0.5.2-py3-none-win_amd64.whl", hash = "sha256:b6c0e4f272f9410f8218d1db0cc7ea10cd8f62db19f820b9ace9f6c2b33bd8dd", size = 5727075, upload-time = "2026-09-02T17:49:23.656Z" }, - { url = "https://files.pythonhosted.org/packages/df/7c/409ced8866197da50e9bcb2c9bf101e3e9a35b98a851c0ccd2a44509eaa5/prek-0.5.2-py3-none-win_arm64.whl", hash = "sha256:0ebea4ef39f6446f6c414a5a506cde8942b17b5899f74cf90e352628085d4005", size = 5489643, upload-time = "2026-09-02T17:49:25.778Z" }, + { url = "https://files.pythonhosted.org/packages/db/5d/8731dc49cb5424d37424db3ea2f3ff6a0b20caa971db72796e822ce48b24/prek-0.5.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d7240c5e6a996ef5bebe5d57d43049803e41c225b343681c020bf0c87dab2b2", size = 6003518, upload-time = "2026-09-13T08:38:20.584Z" }, + { url = "https://files.pythonhosted.org/packages/51/45/ca413aefb3ea2411bbf0adb6eebfac1e9bfc5ac7679dbe26336fc651c347/prek-0.5.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1dd6df8235ca361dbbeec89c305ccb41a138088a2fc548484b4cb89f66a80d20", size = 5538754, upload-time = "2026-09-13T08:38:23.004Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/874d58ba40d2fe7cfec2789ae8bf415f3751666ad5c59928821fe53c3c01/prek-0.5.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:61ff791bb850ba52cc0d86576498df7e1bd1e9ed20df63dae113a4a6aa495e19", size = 5841568, upload-time = "2026-09-13T08:38:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/8f/40/167037b8eef75f6f036c60ff7e93f42defebb7c3733677e2f30e5a479257/prek-0.5.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a863379a2668c4ef079d820ffd5b14073145380dfbe0e70fef498dc73c568f86", size = 6226960, upload-time = "2026-09-13T08:38:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/42/9a/bfcd3c1fe1fdefee8dc144a92c4b0b93b59f31c7df60ee1720c49223a90a/prek-0.5.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:086d1b77557f0a089568dc5b93c22ccd793351524f3eeb29d36781421a350e72", size = 5854832, upload-time = "2026-09-13T08:38:28.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/1450b995b18bbf0d8ab65016860198052baad5c79c231151fa1dcca4a2de/prek-0.5.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4f09b947255124e9591a7f7ef16eb5340a36ba94a275d788d7ea804fab35f0e2", size = 6343055, upload-time = "2026-09-13T08:38:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e8/8f45950b8a8ddcf22efc7183e9d6b1d7e30373229ff7d9e56f918a55922c/prek-0.5.3-py3-none-win_amd64.whl", hash = "sha256:5b74a9742c3e8f8688d5dad1439688f112e2e1746fcb91016015dae38df3eb42", size = 5744293, upload-time = "2026-09-13T08:38:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/24/cd/ce637f4b6cc6c583516dc16b539408dab593672f7c6fe7723a37caf378ff/prek-0.5.3-py3-none-win_arm64.whl", hash = "sha256:20d92aef53a5e237f4659ecc6669c0fbc98a1b83f0e2476530400a72d42ca390", size = 5512958, upload-time = "2026-09-13T08:38:36.123Z" }, ] [[package]] @@ -4412,7 +4409,7 @@ name = "prompt-toolkit" version = "3.0.53" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ @@ -4641,10 +4638,10 @@ name = "pydantic" version = "2.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } wheels = [ @@ -4653,7 +4650,7 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "email-validator" }, ] [[package]] @@ -4661,7 +4658,7 @@ name = "pydantic-argparse" version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/ea/e63d587294c20d3b83e9c312b5d577c9ec28962ee8490839ca9996672849/pydantic_argparse-0.10.0.tar.gz", hash = "sha256:d57eb0a84c8f0af6605376157d3f445cfd786700f2e596ba9d48d15d557185eb", size = 15928, upload-time = "2025-02-09T08:18:30.425Z" } wheels = [ @@ -4673,7 +4670,7 @@ name = "pydantic-core" version = "2.46.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } wheels = [ @@ -4789,9 +4786,9 @@ name = "pydantic-settings" version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ @@ -4812,7 +4809,7 @@ name = "pyjwt" version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", 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 = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ @@ -4821,7 +4818,7 @@ wheels = [ [package.optional-dependencies] crypto = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, ] [[package]] @@ -4829,7 +4826,7 @@ name = "pynacl" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } wheels = [ @@ -4873,8 +4870,8 @@ name = "pyright" version = "1.1.414" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nodeenv" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } wheels = [ @@ -4886,7 +4883,7 @@ name = "pyroscope-io" version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "cffi" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a8/50/607b38b120ba8adad954119ba512c53590c793f0cf7f009ba6549e4e1d77/pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8", size = 3138869, upload-time = "2026-01-22T06:23:24.664Z" }, @@ -4901,12 +4898,12 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", 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 = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -4918,9 +4915,9 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", 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 = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -4932,9 +4929,9 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ @@ -4946,7 +4943,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -5074,7 +5071,7 @@ name = "redis" version = "8.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "async-timeout", marker = "(python_full_version < '3.11.3' and sys_platform == 'darwin') or (python_full_version < '3.11.3' and sys_platform == 'linux') or (python_full_version < '3.11.3' and sys_platform == 'win32')" }, + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" } wheels = [ @@ -5086,10 +5083,10 @@ name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rpds-py", version = "0.30.0", 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 = "rpds-py", version = "2026.6.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 = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -5238,10 +5235,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "charset-normalizer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -5262,9 +5259,9 @@ name = "rich" version = "13.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", 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 = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } wheels = [ @@ -5543,9 +5540,9 @@ name = "rq" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "croniter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "croniter" }, + { name = "redis" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/81/dacb94c8f67606b233cb7836dd67042daf9a61f7b585dcec65113f1e71f7/rq-2.12.0.tar.gz", hash = "sha256:78116d0c860f6285817b52d7d6d0b16a726372073ce8ea1d229732ce74ef9378", size = 760892, upload-time = "2026-08-30T12:05:25.048Z" } wheels = [ @@ -5582,7 +5579,7 @@ name = "s3transfer" version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "botocore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ @@ -5599,10 +5596,10 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "joblib", 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", version = "2.2.6", 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 = "scipy", version = "1.15.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 = "threadpoolctl", 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 = "joblib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -5660,13 +5657,13 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "joblib", 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 = "narwhals", 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", version = "2.4.6", 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 = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.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 = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "threadpoolctl", 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 = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -5712,7 +5709,7 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", 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 = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -5773,7 +5770,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", 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 = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5858,7 +5855,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ @@ -5929,13 +5926,13 @@ name = "seaborn" version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib", version = "3.10.9", 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 = "matplotlib", version = "3.11.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 = "numpy", version = "2.2.6", 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 = "numpy", version = "2.4.6", 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 = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pandas", version = "2.3.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 = "pandas", version = "3.0.5", 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 = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } wheels = [ @@ -6058,11 +6055,11 @@ name = "soundfile" version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.2.6", 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 = "numpy", version = "2.4.6", 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 = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } wheels = [ @@ -6081,8 +6078,8 @@ name = "sse-starlette" version = "3.4.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "starlette" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } wheels = [ @@ -6094,8 +6091,8 @@ name = "starlette" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ @@ -6107,7 +6104,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mpmath" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ @@ -6128,34 +6125,34 @@ name = "tau2" version = "0.0.1" source = { git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619#5ba9e3e56db57c5e4114bf7f901291f09b2c5619" } dependencies = [ - { name = "addict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "deepdiff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "langfuse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "matplotlib", version = "3.10.9", 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 = "matplotlib", version = "3.11.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 = "pandas", version = "2.3.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 = "pandas", version = "3.0.5", 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 = "plotly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-argparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "scikit-learn", version = "1.7.2", 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 = "scikit-learn", version = "1.9.0", 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 = "seaborn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tabulate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "toml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "addict" }, + { name = "deepdiff" }, + { name = "docstring-parser" }, + { name = "fastapi" }, + { name = "fs" }, + { name = "langfuse" }, + { name = "litellm" }, + { name = "loguru" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "plotly" }, + { name = "psutil" }, + { name = "pydantic-argparse" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "rich" }, + { name = "ruff" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "seaborn" }, + { name = "tabulate" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "watchdog" }, ] [[package]] @@ -6190,8 +6187,8 @@ name = "tiktoken" version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "regex" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" } wheels = [ @@ -6258,7 +6255,7 @@ name = "tokenizers" version = "0.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/1e/bc6587c5ab643b2e17776cace9070a2ae73549c86bffac9934a600bf3c31/tokenizers-0.23.2.tar.gz", hash = "sha256:7f0f085686b9de0d0079e6f874ae053600db64c5d13049e0bbc0119926d25aac", size = 385745, upload-time = "2026-09-03T08:55:42.89Z" } wheels = [ @@ -6378,7 +6375,7 @@ name = "typing-inspection" version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ @@ -6446,9 +6443,9 @@ name = "uvicorn" version = "0.52.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", 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 = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } wheels = [ @@ -6457,12 +6454,12 @@ wheels = [ [package.optional-dependencies] standard = [ - { name = "httptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvloop", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, - { name = "watchfiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, ] [[package]] @@ -6470,8 +6467,8 @@ name = "uvicorn-worker" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "gunicorn" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } wheels = [ @@ -6559,7 +6556,7 @@ name = "watchfiles" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -6744,7 +6741,7 @@ name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ @@ -6834,9 +6831,9 @@ name = "yarl" version = "1.24.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } wheels = [ From 45837051c6701c9df24a2d195cf7b63dbc6e0eae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:13:29 +0000 Subject: [PATCH 02/42] Build(deps-dev): Bump prek (#8610) Bumps the basics group with 1 update in the /python/packages/lab directory: [prek](https://github.com/j178/prek). Updates `prek` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.5.2...v0.5.3) --- updated-dependencies: - dependency-name: prek dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: basics ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 4f0cc5be7541ca513b82eb6607f52b0ae856fc31 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Tue, 22 Sep 2026 12:45:48 +0000 Subject: [PATCH 03/42] Python: [BREAKING] Preserve MCP runtime and approval context (#8589) * Python: [BREAKING] Preserve MCP runtime and approval context * Docs: shorten MCP approval compatibility note * Docs: keep approval compatibility details out of package overview --- .../0043-python-mcp-runtime-context.md | 48 +++++ python/packages/core/AGENTS.md | 2 +- python/packages/core/agent_framework/_mcp.py | 30 +++- .../_workflows/_agent_executor.py | 9 +- .../core/tests/core/test_mcp_http_auth.py | 117 +++++++++++- .../tests/workflow/test_agent_executor.py | 10 +- python/packages/declarative/AGENTS.md | 16 ++ .../_workflows/_executors_agents.py | 32 ++-- .../_workflows/_executors_mcp.py | 108 ++++++++--- .../test_declarative_approval_binding.py | 103 ++++++++++- .../declarative/tests/test_graph_coverage.py | 8 +- .../declarative/tests/test_graph_executors.py | 168 +++++++++++++----- .../tests/test_invoke_mcp_tool_executor.py | 108 +++++++++-- 13 files changed, 617 insertions(+), 142 deletions(-) create mode 100644 docs/decisions/0043-python-mcp-runtime-context.md diff --git a/docs/decisions/0043-python-mcp-runtime-context.md b/docs/decisions/0043-python-mcp-runtime-context.md new file mode 100644 index 00000000000..7bdbb60331a --- /dev/null +++ b/docs/decisions/0043-python-mcp-runtime-context.md @@ -0,0 +1,48 @@ +--- +status: proposed +date: 2026-09-21 +deciders: eavanvalkenburg, westey-m +--- + +# Preserve host runtime context through MCP invocation and approval + +## Context and Problem Statement + +Workflow client kwargs, tool runtime kwargs, model arguments, and reviewed transport headers have different +destinations and lifetimes. Flattening them loses provenance; retaining only an operation across a delayed approval +does not preserve the transport context under which that operation was reviewed. + +## Considered Options + +- Reverse precedence in the shared argument mapping. This protects collisions but changes tool arguments and still + permits model-only values to supply authentication inputs. +- Persist evaluated headers or an unkeyed digest with the approval. This supports restart-transparent comparison but + exposes credentials or permits offline guessing of low-entropy header values. +- Use executor-local ephemeral binding keys. This keeps keys out of storage, but can cause endless reapproval when + each resume reconstructs the executor on a different worker. +- Separate host runtime inputs and retain keyed approval verification in trusted workflow checkpoint state. + This keeps credentials out of checkpoints and keys out of approval payloads, but requires protected host storage. + +## Decision Outcome + +Choose separate host inputs and workflow-local keyed bindings: + +- Declarative agent execution resolves both kwargs buckets per executor, including legacy checkpoint state, and + forwards them through their matching Agent parameters. It does not copy the outer bag into tool inputs. +- Generated MCP functions preserve host runtime kwargs in an owner-scoped context separate from merged tool + arguments. HTTP header providers read only host context. The scope is reset on success, error, and cancellation. + Direct host calls keep their existing kwargs-based API; model precedence remains unchanged for tool arguments. +- Declarative MCP approvals contain header names and an HMAC over the request ID and canonical evaluated headers. + The random binding key is retained separately in trusted host checkpoint state, never in approval payloads. + Header names compare case-insensitively, values exactly; no raw credentials or unkeyed digests enter checkpoints. +- A changed or unverifiable header set produces a fresh approval for the pinned operation before dispatch. This + includes legacy requests, credential rotation, and missing verification state. Unchanged approvals remain valid + across executor reconstruction using the checkpointed key. Headerless requests retain their resume behavior. + +These are intentional compatibility changes: applications deriving generated-call headers from model arguments must +move trusted values into host runtime context or a provider closure. Consumers of delayed approvals must handle +replacement request IDs. Checkpoints already hold approval authority and must be protected against unauthorized reads +and writes; the binding key inherits that host trust boundary. Custom handlers and client providers remain responsible for +principal changes in credentials resolved outside the action's evaluated headers. + +The proposed deciders are Python code owners; maintainer and engineering-management approval is still required. diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index aabfef33b0b..61cd052f99b 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -184,7 +184,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. - **Tool discovery refresh** - Validate the complete current tool snapshot alongside retained non-tool functions, not stale tool wrappers. Successful refreshes atomically replace the previous tool set and metadata, including empty snapshots, while preserving prompts, custom functions, and unchanged tool wrappers (including caller customizations). Removed or replaced tool identities lose their persisted progressive-loaded state; failed refreshes preserve the previous state. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through — but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used. -- **MCP HTTP header request scoping** - `static_headers` supplies fixed, origin-scoped headers without serializing concurrent calls; `header_provider` resolves dynamic per-call headers under the instance call lock. Fixed and dynamic headers form the session's complete effective identity (case-insensitive names, case-sensitive values), with dynamic values overriding fixed values of the same name. Agent runs reconcile a connected tool before copying its functions; generated tool and prompt calls perform the same check at invocation. Connected run preparation defers when a provider needs model-supplied values that are not available until invocation, where resolution remains strict. A different identity waits for in-flight discovery, reconnects framework-created sessions without loading, then refreshes session-derived discovery under the same lock. Because a caller-supplied session's established identity is unknown and the wrapper cannot reconnect it, dynamic header resolution on that wrapper is rejected even while disconnected. Ambient requests retain the bound set. With a shared `http_client`, processing stays scoped to the originating `MCPStreamableHTTPTool`, and every injected header is stripped from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. +- **MCP HTTP header request scoping** - `static_headers` supplies fixed, origin-scoped headers without serializing concurrent calls. Generated tool and prompt calls give `header_provider` only host runtime kwargs, separately from model arguments; direct `call_tool` calls give it caller kwargs. Model-over-runtime precedence is confined to outbound tool arguments. Fixed and dynamic headers form the session's complete effective identity (case-insensitive names, case-sensitive values), with dynamic values overriding fixed values of the same name. Agent runs reconcile a connected tool before copying its functions; generated tool and prompt calls perform the same check at invocation. Connected run preparation defers when a provider needs runtime values supplied by invocation middleware; invocation-time resolution remains strict. A different identity waits for in-flight discovery, reconnects framework-created sessions without loading, then refreshes session-derived discovery under the same lock. Because a caller-supplied session's established identity is unknown and the wrapper cannot reconnect it, dynamic header resolution on that wrapper is rejected even while disconnected. Ambient requests retain the bound set. With a shared `http_client`, processing stays scoped to the originating `MCPStreamableHTTPTool`, and every injected header is stripped from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. - **MCP lifecycle caller cancellation** - The lifecycle owner skips cancelled queued connect requests and waits for the caller to acknowledge successful setup. If the caller cancels before accepting a newly established connection, the owner tears it down before processing the next request. Cancelling a close waiter does not interrupt teardown, and cancelling a redundant connect does not discard a previously established session. - **Streamable HTTP cookies** - Framework-created clients reject response-cookie persistence with or without a `header_provider`; explicit `Cookie` headers from `static_headers` or a provider remain supported. Caller-provided clients retain their cookie behavior and ownership. Applications requiring cookie persistence must scope clients and MCP sessions to one authenticated principal. Cookie rejection does not isolate MCP protocol sessions or other server-side state. - **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this — it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`. diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 82071d6991d..8c626de1309 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -165,6 +165,9 @@ class MCPSpecificApproval(TypedDict, total=False): "_meta", }) _mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar("_mcp_call_headers") +_mcp_tool_runtime_context: contextvars.ContextVar[tuple[object, Mapping[str, Any]] | None] = contextvars.ContextVar( + "_mcp_tool_runtime_context", default=None +) _MCP_HEADER_OWNER_EXTENSION = "agent_framework.mcp_header_owner" _MCP_INJECTED_HEADER_KEYS_EXTENSION = "agent_framework.mcp_injected_header_keys" _MCPHeaderIdentity: TypeAlias = tuple[tuple[str, str], ...] @@ -624,10 +627,12 @@ async def _call_tool_with_runtime_kwargs( apply_server_meta_to_model_items=mcp_tool.parse_tool_results is None, ) token = _mcp_host_payload_capture.set(capture) + runtime_token = _mcp_tool_runtime_context.set((mcp_tool, dict(ctx.kwargs))) try: parsed = await mcp_tool.call_tool(remote_tool_name, **call_kwargs) return capture.prepare_model_result(parsed) finally: + _mcp_tool_runtime_context.reset(runtime_token) _mcp_host_payload_capture.reset(token) ctx.metadata[_FUNCTION_RESULT_CARRIER_CONTEXT_KEY] = capture.to_carrier() @@ -3841,6 +3846,10 @@ def __init__( header_provider: Optional callable that receives the runtime keyword arguments (from ``FunctionInvocationContext.kwargs``) and returns a ``dict[str, str]`` of HTTP headers to inject into every outbound request to the MCP server. + Generated tool calls supply only this host runtime context, never model + arguments, even when names collide. Direct ``call_tool`` calls use their + caller-supplied kwargs. Model-over-runtime precedence applies only to + outbound tool arguments, not to authentication. Use this to forward per-request context (e.g. authentication tokens set in agent middleware) without creating a separate ``httpx.AsyncClient``. The complete header set used to initialize a connection becomes that session's @@ -3860,7 +3869,7 @@ def __init__( during the handshake is raised instead, since a missing key there is a misconfiguration rather than an unavoidable gap. For an already-connected tool, run preparation defers identity reconciliation when the provider needs a - model-supplied argument that is unavailable until invocation; provider errors at + runtime value supplied by invocation middleware; provider errors at invocation still propagate. A credential that must authenticate an eager handshake must therefore come from somewhere the provider can read without a run, such as a closure or ``ContextVar``. A lazy connection established by an agent @@ -4133,9 +4142,8 @@ async def _prepare_for_run(self, kwargs: Mapping[str, Any]) -> None: try: headers = self._effective_headers(kwargs) except KeyError: - # Some providers intentionally read model-supplied tool arguments that - # do not exist until invocation. Keep preparation non-breaking and let - # the strict invocation-time resolution reconcile the session later. + # Invocation middleware may supply runtime values unavailable during + # preparation. Strict invocation-time resolution reconciles the session later. logger.debug( "Deferring MCP header identity reconciliation for %r until invocation.", self.name, @@ -4263,14 +4271,16 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: When a ``header_provider`` was supplied at construction time, the runtime *kwargs* (originating from ``FunctionInvocationContext.kwargs``) are passed - to the provider. The returned headers are attached to every HTTP request + to the provider independently of model arguments in generated tool calls. + Direct callers supply both headers' inputs and tool arguments through *kwargs*. + The returned headers are attached to every HTTP request made during this tool call via a request hook on the underlying HTTP client. Fixed and dynamic headers form the effective identity. If they differ from a framework-created session's identity, the tool reconnects before sending the call; caller-supplied sessions reject the change. - The provider does not consume the kwargs: the same mapping continues to - :meth:`MCPTool.call_tool` and its outbound argument filter. + The provider does not consume runtime kwargs: their separately merged tool + argument mapping continues to :meth:`MCPTool.call_tool` and its outbound filter. Args: tool_name: The name of the tool to call. @@ -4282,9 +4292,11 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: A list of Content items representing the tool output. """ if self._header_provider is not None: - headers = self._effective_headers(kwargs) + runtime_context = _mcp_tool_runtime_context.get() + header_kwargs = runtime_context[1] if runtime_context is not None and runtime_context[0] is self else kwargs + headers = self._effective_headers(header_kwargs) async with self._call_headers_lock: - await self._ensure_session_identity(headers, kwargs) + await self._ensure_session_identity(headers, header_kwargs) token = _mcp_call_headers.set(headers) self._active_call_headers = headers try: diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index b9acf5ada32..73958162967 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -82,8 +82,7 @@ def _validate_agent_executor_checkpoint_state(state: Mapping[str, Any]) -> None: for index, item in enumerate(messages): if not isinstance(item, Message): raise WorkflowCheckpointException( - f"AgentExecutor checkpoint field '{key}'[{index}] must be Message, " - f"got {type(item).__name__}." + f"AgentExecutor checkpoint field '{key}'[{index}] must be Message, got {type(item).__name__}." ) if (responses_raw := state.get("pending_responses_to_agent")) is not None: @@ -124,8 +123,7 @@ def _validate_agent_executor_checkpoint_state(state: Mapping[str, Any]) -> None: if (session_raw := state.get("agent_session")) is not None: if not isinstance(session_raw, dict): raise WorkflowCheckpointException( - "AgentExecutor checkpoint field 'agent_session' must be a dict, " - f"got {type(session_raw).__name__}." + f"AgentExecutor checkpoint field 'agent_session' must be a dict, got {type(session_raw).__name__}." ) session = cast(dict[str, Any], session_raw) session_id = session.get("session_id") @@ -530,8 +528,7 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: self._session = AgentSession.from_dict(session_payload) except Exception as exc: raise WorkflowCheckpointException( - "AgentExecutor checkpoint field 'agent_session' could not be restored: " - f"{exc}" + f"AgentExecutor checkpoint field 'agent_session' could not be restored: {exc}" ) from exc else: self._session = self._agent.create_session() diff --git a/python/packages/core/tests/core/test_mcp_http_auth.py b/python/packages/core/tests/core/test_mcp_http_auth.py index 1fd14a52844..b0bc05ce70a 100644 --- a/python/packages/core/tests/core/test_mcp_http_auth.py +++ b/python/packages/core/tests/core/test_mcp_http_auth.py @@ -60,7 +60,17 @@ async def handle(request: httpx.Request) -> httpx.Response: "tools": [ { "name": "record", - "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}}, + "inputSchema": { + "type": "object", + "properties": { + "marker": {"type": "string"}, + **( + {"credential": {"type": "string"}} + if request.headers.get("X-Test-Declare-Credential") == "true" + else {} + ), + }, + }, }, { "name": f"{principal}-only", @@ -128,12 +138,13 @@ async def mcp_http_server(mcp_http_client_factory: MCPHTTPClientFactory) -> Asyn yield client, requests, writes -def _tool(client: httpx.AsyncClient, principal: str) -> MCPStreamableHTTPTool: +def _tool(client: httpx.AsyncClient, principal: str, *, declare_credential: bool = False) -> MCPStreamableHTTPTool: return MCPStreamableHTTPTool( name=principal, url="https://mcp.example/mcp", http_client=client, load_prompts=False, + static_headers={"X-Test-Declare-Credential": "true"} if declare_credential else None, header_provider=lambda kwargs: {"Authorization": kwargs.get("credential", principal)}, ) @@ -547,6 +558,108 @@ async def test_concurrent_callers_use_sessions_bound_to_their_own_headers(mcp_ht await tool.close() +@pytest.mark.parametrize("model_credential", ["token-b", ""]) +async def test_generated_tool_keeps_authentication_in_runtime_context( + mcp_http_server: MCPHTTPServer, model_credential: str +) -> None: + client, requests, _ = mcp_http_server + tool = _tool(client, "token-a", declare_credential=True) + try: + await tool.connect() + function = next(function for function in tool.functions if function.name == "record") + arguments = {"marker": "model-value", "credential": model_credential} + context = FunctionInvocationContext( + function=function, arguments=arguments, kwargs={"credential": "token-a", "marker": "runtime-value"} + ) + await function.invoke(arguments=arguments, context=context) + + call = _calls(requests)[-1] + assert call.headers["Authorization"] == "token-a" + assert call.headers["mcp-session-id"] == "session-token-a" + assert json.loads(call.content)["params"]["arguments"]["marker"] == "model-value" + assert json.loads(call.content)["params"]["arguments"]["credential"] == model_credential + assert len(_requests_for_method(requests, "initialize")) == 1 + assert context.kwargs == {"credential": "token-a", "marker": "runtime-value"} + finally: + await tool.close() + + +async def test_generated_tool_does_not_supply_model_only_authentication(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + tool = _tool(client, "token-a", declare_credential=True) + try: + await tool.connect() + function = next(function for function in tool.functions if function.name == "record") + arguments = {"marker": "model-value", "credential": "token-b"} + context = FunctionInvocationContext(function=function, arguments=arguments, kwargs={}) + await function.invoke(arguments=arguments, context=context) + assert _calls(requests)[-1].headers["Authorization"] == "token-a" + + # Direct calls remain host-controlled and must not inherit the generated invocation's context. + await tool.call_tool("record", credential="token-b", marker="direct") + assert _calls(requests)[-1].headers["Authorization"] == "token-b" + finally: + await tool.close() + + +@pytest.mark.parametrize("failure", [KeyError, asyncio.CancelledError]) +async def test_generated_tool_auth_context_is_cleared_after_failure( + mcp_http_server: MCPHTTPServer, failure: type[BaseException] +) -> None: + client, requests, _ = mcp_http_server + + def headers(kwargs: dict[str, Any]) -> dict[str, str]: + if "credential" not in kwargs: + raise failure("credential") + return {"Authorization": kwargs["credential"]} + + tool = MCPStreamableHTTPTool( + name="runtime-auth", + url="https://mcp.example/mcp", + http_client=client, + load_prompts=False, + header_provider=headers, + ) + tool._seed_connection_kwargs({"credential": "token-a"}) + try: + await tool.connect() + function = next(function for function in tool.functions if function.name == "record") + arguments = {"credential": "token-b"} + context = FunctionInvocationContext(function=function, arguments=arguments, kwargs={}) + with pytest.raises(failure, match="credential"): + await function.invoke(arguments=arguments, context=context) + assert not _calls(requests) + + await tool.call_tool("record", credential="token-b") + assert _calls(requests)[-1].headers["Authorization"] == "token-b" + finally: + await tool.close() + + +async def test_concurrent_generated_calls_keep_separate_auth_contexts(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + tool = _tool(client, "token-a", declare_credential=True) + try: + await tool.connect() + function = next(function for function in tool.functions if function.name == "record") + + async def invoke(principal: str, model_principal: str) -> None: + arguments = {"marker": principal, "credential": model_principal} + context = FunctionInvocationContext( + function=function, arguments=arguments, kwargs={"credential": principal} + ) + await function.invoke(arguments=arguments, context=context) + + await asyncio.gather(invoke("token-a", "token-b"), invoke("token-b", "token-a")) + calls = _calls(requests) + assert len(calls) == 2 + for request in calls: + assert request.headers["Authorization"] == json.loads(request.content)["params"]["arguments"]["marker"] + assert request.headers["mcp-session-id"] == f"session-{request.headers['Authorization']}" + finally: + await tool.close() + + async def test_header_identity_normalizes_names_but_preserves_value_case(mcp_http_server: MCPHTTPServer) -> None: client, requests, writes = mcp_http_server diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index b2a21c4d707..97c6ed86f2e 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -698,12 +698,10 @@ async def test_agent_executor_checkpoint_restore_ignores_unknown_keys() -> None: agent = _CountingAgent(id="fwd_agent", name="FwdAgent") executor = AgentExecutor(agent) - await executor.on_checkpoint_restore( - { - "cache": [Message(role="user", contents=["ok"])], - "future_field": {"ignored": True}, - } - ) + await executor.on_checkpoint_restore({ + "cache": [Message(role="user", contents=["ok"])], + "future_field": {"ignored": True}, + }) assert len(executor._cache) == 1 # pyright: ignore[reportPrivateUsage] assert executor._cache[0].text == "ok" # pyright: ignore[reportPrivateUsage] diff --git a/python/packages/declarative/AGENTS.md b/python/packages/declarative/AGENTS.md index 17229ecaa9c..9638b435337 100644 --- a/python/packages/declarative/AGENTS.md +++ b/python/packages/declarative/AGENTS.md @@ -27,6 +27,22 @@ Per-invocation sessions intentionally add connection overhead and lose server session continuity between invocations; shared session ownership requires an explicitly scoped custom `MCPToolHandler`. +## MCP Approval Context + +`InvokeMcpToolActionExecutor` binds evaluated headers to each request with a +workflow-local HMAC key held separately in trusted host checkpoint state. +Only the opaque binding and header names enter the approval payload; raw headers +are not checkpointed. Changed or unverifiable headers produce a replacement +request for the same pinned operation, with a fresh request ID and no dispatch. +Fresh executors verify unchanged approvals using the checkpointed key; legacy +requests or missing verification state require reapproval for non-empty headers. +Custom handlers remain responsible for identity changes +in credentials they resolve outside the action's headers. + +`InvokeAzureAgent` uses Core's executor-ready kwargs conversion for both modern +and legacy state, without copying workflow/client kwargs into additional tool +arguments. Explicit caller tool arguments in options are preserved. + ## External Input Handling - **`ExternalInputRequest`** / **`ExternalInputResponse`** - Human-in-the-loop support diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index 247f4a10856..30de4b7eb00 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -807,28 +807,16 @@ async def _invoke_agent_and_store_results( _validate_conversation_history(messages_for_agent, agent_name) # Retrieve kwargs passed to workflow.run() so they propagate to agent tools - from agent_framework._workflows import _agent_utils as workflow_agent_utils - from agent_framework._workflows import _const as workflow_const - - run_kwargs: dict[str, Any] = ctx.get_state(workflow_const.WORKFLOW_RUN_KWARGS_KEY, {}) - prepare_run_kwargs = getattr(workflow_agent_utils, "prepare_executor_run_kwargs", None) - resolved_state_key = getattr(workflow_const, "RESOLVED_WORKFLOW_RUN_KWARGS_KEY", None) - if callable(prepare_run_kwargs) and isinstance(resolved_state_key, str): - missing_resolved_state = object() - resolved_run_kwargs: Any = ctx.get_state(resolved_state_key, missing_resolved_state) - if resolved_run_kwargs is not missing_resolved_state: - if not isinstance(resolved_run_kwargs, dict): - raise TypeError("Resolved workflow run kwargs state must be a dict.") - run_kwargs = cast(Any, prepare_run_kwargs)(self.id, run_kwargs, resolved_run_kwargs) - options: dict[str, Any] | None = None - if run_kwargs: - # Merge caller-provided options to avoid duplicate keyword argument - options = dict(run_kwargs.get("options") or {}) - options["additional_function_arguments"] = run_kwargs - # Exclude 'options' from splat to avoid TypeError on duplicate keyword, - # and keep internal workflow-routing copies (stored under underscore - # keys for nested executors) out of the public Agent.run signature - run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options" and not k.startswith("_")} + from agent_framework._workflows._agent_utils import prepare_executor_run_kwargs + from agent_framework._workflows._const import RESOLVED_WORKFLOW_RUN_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY + + run_kwargs = prepare_executor_run_kwargs( + self.id, + ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}), + ctx.get_state(RESOLVED_WORKFLOW_RUN_KWARGS_KEY), + ) + options = run_kwargs.pop("options", None) + run_kwargs = {k: v for k, v in run_kwargs.items() if not k.startswith("_")} # Use run() method to get properly structured messages (including tool calls and results) # This is critical for multi-turn conversations where tool calls must be followed diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_mcp.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_mcp.py index e025a262125..7d3647d00bd 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_mcp.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_mcp.py @@ -13,16 +13,20 @@ - Approval requests surface header NAMES only; header values are not echoed, matching the posture of :mod:`._executors_http`. - :class:`MCPToolApprovalRequest` carries the values the resume handler will - use; header values are re-evaluated on resume to keep secrets out of - checkpoint state. + use; header values are re-evaluated and checked against a workflow-local + keyed binding. Changed or unverifiable headers require fresh approval. + The key stays in trusted host checkpoint state, never in approval payloads; + header values are not checkpointed. - Tool outputs flow back into agent conversations through ``conversationId`` and through Tool-role messages emitted to ``output.messages``. They share the same prompt-injection risk surface as ``HttpRequestAction``: workflow authors must trust the MCP server they invoke. """ +import hmac import json import logging +import secrets import uuid from collections.abc import Mapping from dataclasses import dataclass, field @@ -53,6 +57,7 @@ ] logger = logging.getLogger(__name__) +_HEADER_BINDING_KEY = "_declarative_mcp_header_binding_key" # --------------------------------------------------------------------------- @@ -74,6 +79,8 @@ class MCPToolApprovalRequest: connection_name: Connection identifier the invocation will use. metadata: Internal routing data pinned at approval-request time (e.g. ``conversation_id``) for use by the resume handler. + header_binding: Opaque binding of the reviewed headers. The verification + key is retained separately in trusted workflow state. """ request_id: str @@ -84,6 +91,7 @@ class MCPToolApprovalRequest: header_names: list[str] = field(default_factory=lambda: []) connection_name: str | None = None metadata: dict[str, Any] = field(default_factory=lambda: {}) + header_binding: str | None = None # --------------------------------------------------------------------------- @@ -156,7 +164,8 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor): - When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest` via ``ctx.request_info()`` and yields. On resume, the response is checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and - no tool call is made. + no tool call is made. Changed headers, legacy unbound headers, or a + missing verification state require another approval. - On success: parses each :class:`agent_framework.Content` output (text → JSON-first / data / uri → URI string) and assigns the parsed list to ``output.result``. Builds a single Tool-role :class:`Message` @@ -220,29 +229,6 @@ async def handle_action( output_messages_path = _get_output_path(self._action_def, "messages") output_result_path = _get_output_path(self._action_def, "result") - if require_approval: - request_id = str(uuid.uuid4()) - conversation_id = _evaluate_conversation_id(state, conversation_id_expr) - request = MCPToolApprovalRequest( - request_id=request_id, - tool_name=tool_name, - server_url=server_url, - server_label=server_label, - arguments=arguments, - header_names=sorted(headers.keys()), - connection_name=connection_name, - metadata={"conversation_id": conversation_id}, - ) - logger.info( - "%s: requesting approval for MCP tool '%s' on '%s'", - self.__class__.__name__, - tool_name, - server_url, - ) - await ctx.request_info(request, ToolApprovalResponse, request_id=request_id) - return - - # No approval required - invoke directly. invocation = MCPToolInvocation( server_url=server_url, tool_name=tool_name, @@ -251,6 +237,12 @@ async def handle_action( headers=headers, connection_name=connection_name, ) + if require_approval: + await self._request_approval( + invocation, {"conversation_id": _evaluate_conversation_id(state, conversation_id_expr)}, ctx + ) + return + result = await self._invoke_with_narrow_catch(invocation) await self._process_result( ctx=ctx, @@ -265,6 +257,54 @@ async def handle_action( # ----- Approval response handler ------------------------------------------ + def _bind_headers( + self, + ctx: WorkflowContext[ActionComplete, str], + request_id: str, + headers: Mapping[str, str], + *, + create_key: bool = False, + ) -> str | None: + key = ctx.state.get(_HEADER_BINDING_KEY, None) + if key is None: + if not create_key: + return None + key = secrets.token_hex(32) + ctx.state.set(_HEADER_BINDING_KEY, key) + if not isinstance(key, str) or len(key) != 64 or any(char not in "0123456789abcdef" for char in key): + raise ValueError("Invalid MCP approval header binding state.") + # Stable sorting preserves the order of duplicate case-insensitive names. + canonical_headers = sorted(((name.lower(), value) for name, value in headers.items()), key=lambda item: item[0]) + payload = json.dumps( + [request_id, canonical_headers], + ensure_ascii=True, + separators=(",", ":"), + ).encode() + return hmac.digest(key.encode(), payload, "sha256").hex() + + async def _request_approval( + self, + invocation: MCPToolInvocation, + metadata: dict[str, Any], + ctx: WorkflowContext[ActionComplete, str], + ) -> None: + request_id = str(uuid.uuid4()) + request = MCPToolApprovalRequest( + request_id=request_id, + tool_name=invocation.tool_name, + server_url=invocation.server_url, + server_label=invocation.server_label, + arguments=invocation.arguments, + header_names=sorted(invocation.headers), + connection_name=invocation.connection_name, + metadata=metadata, + header_binding=( + self._bind_headers(ctx, request_id, invocation.headers, create_key=True) if invocation.headers else None + ), + ) + logger.info("%s: requesting approval for MCP tool '%s'", self.__class__.__name__, invocation.tool_name) + await ctx.request_info(request, ToolApprovalResponse, request_id=request_id) + @response_handler async def handle_approval_response( self, @@ -272,7 +312,7 @@ async def handle_approval_response( response: ToolApprovalResponse, ctx: WorkflowContext[ActionComplete, str], ) -> None: - """Resume the invocation using the values pinned on ``original_request``.""" + """Resume the pinned operation only with the reviewed header context.""" state = self._get_state(ctx.state) tool_name = original_request.tool_name @@ -303,6 +343,20 @@ async def handle_approval_response( headers=self._evaluate_headers(state, self._action_def.get("headers")), connection_name=getattr(original_request, "connection_name", None), ) + if invocation.headers or original_request.header_names: + binding = getattr(original_request, "header_binding", None) + expected_binding = self._bind_headers(ctx, original_request.request_id, invocation.headers) + if ( + not isinstance(binding, str) + or expected_binding is None + or not hmac.compare_digest(binding.encode(), expected_binding.encode()) + ): + logger.warning( + "%s: MCP header context changed or could not be verified; requesting fresh approval.", + self.__class__.__name__, + ) + await self._request_approval(invocation, dict(metadata), ctx) + return result = await self._invoke_with_narrow_catch(invocation) await self._process_result( ctx=ctx, diff --git a/python/packages/declarative/tests/test_declarative_approval_binding.py b/python/packages/declarative/tests/test_declarative_approval_binding.py index 61586a7476e..441f73891d0 100644 --- a/python/packages/declarative/tests/test_declarative_approval_binding.py +++ b/python/packages/declarative/tests/test_declarative_approval_binding.py @@ -17,7 +17,7 @@ * Resume works on a freshly constructed executor (checkpoint-restore simulation), without any prior ``ctx.state`` write. * For MCP, ``connection_name`` is sourced from the approval payload and - ``headers`` are re-evaluated from the action definition on resume. + re-evaluated headers require a matching binding or a fresh approval. """ import sys @@ -400,8 +400,8 @@ async def test_concurrent_pending_mcp_approvals_do_not_swap(self, mock_state, mo assert handler.invocations[1].connection_name == "conn-A" @pytest.mark.asyncio - async def test_headers_reevaluated_from_action_def_on_resume(self, mock_state, mock_context) -> None: - """Headers come from the action definition (re-evaluated) so secrets are not in the payload.""" + async def test_legacy_headers_require_fresh_approval(self, mock_state, mock_context) -> None: + """A legacy request cannot establish which headers the reviewer approved.""" _seed_state(mock_state) handler = _RecordingMcpHandler() executor = InvokeMcpToolActionExecutor( @@ -419,9 +419,106 @@ async def test_headers_reevaluated_from_action_def_on_resume(self, mock_state, m ) await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context) + assert handler.last is None + replacement = mock_context.request_info.call_args.args[0] + assert replacement.request_id != request.request_id + assert replacement.arguments == request.arguments + await executor.handle_approval_response(replacement, ToolApprovalResponse(approved=True), mock_context) assert handler.last is not None assert handler.last.headers == {"Authorization": "Bearer tk"} + @pytest.mark.parametrize("new_headers", [{}, {"Authorization": "different"}, {"X-Context": "added"}]) + @pytest.mark.asyncio + async def test_changed_header_set_never_consumes_previous_approval( + self, mock_state, mock_context, new_headers + ) -> None: + from agent_framework_declarative._workflows import ActionTrigger + + _seed_state(mock_state) + handler = _RecordingMcpHandler() + action = self._action(headers={"Authorization": "original"}) + executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=handler) + await executor.handle_action(ActionTrigger(), mock_context) + request = mock_context.request_info.call_args.args[0] + action["headers"] = new_headers + + await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context) + + assert handler.call_count == 0 + replacement = mock_context.request_info.call_args.args[0] + assert replacement.request_id != request.request_id + assert replacement.header_names == sorted(new_headers) + await executor.handle_approval_response(replacement, ToolApprovalResponse(approved=False), mock_context) + assert handler.call_count == 0 + + @pytest.mark.asyncio + async def test_header_binding_normalizes_names_and_order(self, mock_state, mock_context) -> None: + from agent_framework_declarative._workflows import ActionTrigger + + _seed_state(mock_state) + handler = _RecordingMcpHandler() + action = self._action(headers={"Authorization": "original", "X-Context": "context"}) + executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=handler) + await executor.handle_action(ActionTrigger(), mock_context) + request = mock_context.request_info.call_args.args[0] + action["headers"] = {"x-context": "context", "authorization": "original"} + + await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context) + + assert handler.call_count == 1 + mock_context.request_info.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize("retain_binding_key", [False, True]) + async def test_fresh_executor_verifies_checkpoint_header_binding( + self, mock_state, mock_context, retain_binding_key + ) -> None: + from agent_framework_declarative._workflows import ActionTrigger + from agent_framework_declarative._workflows._executors_mcp import _HEADER_BINDING_KEY + + _seed_state(mock_state) + handler = _RecordingMcpHandler() + action = self._action(headers={"Authorization": "original"}) + original = InvokeMcpToolActionExecutor(action, mcp_tool_handler=handler) + await original.handle_action(ActionTrigger(), mock_context) + request = mock_context.request_info.call_args.args[0] + fresh = InvokeMcpToolActionExecutor(action, mcp_tool_handler=handler) + + key = mock_state.get(_HEADER_BINDING_KEY) + assert key not in repr(request) + if not retain_binding_key: + mock_state.delete(_HEADER_BINDING_KEY) + + await fresh.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context) + + if retain_binding_key: + assert handler.call_count == 1 + mock_context.request_info.assert_awaited_once() + return + assert handler.call_count == 0 + replacement = mock_context.request_info.call_args.args[0] + assert replacement.request_id != request.request_id + assert replacement.header_binding != request.header_binding + await fresh.handle_approval_response(replacement, ToolApprovalResponse(approved=True), mock_context) + assert handler.call_count == 1 + + @pytest.mark.asyncio + async def test_duplicate_header_name_order_requires_reapproval(self, mock_state, mock_context) -> None: + from agent_framework_declarative._workflows import ActionTrigger + + _seed_state(mock_state) + handler = _RecordingMcpHandler() + action = self._action(headers={"Authorization": "first", "authorization": "second"}) + executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=handler) + await executor.handle_action(ActionTrigger(), mock_context) + request = mock_context.request_info.call_args.args[0] + action["headers"] = {"authorization": "second", "Authorization": "first"} + + await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context) + + assert handler.call_count == 0 + assert mock_context.request_info.call_args.args[0].request_id != request.request_id + @pytest.mark.asyncio async def test_mcp_resume_ignores_stale_state_at_old_approval_key(self, mock_state, mock_context) -> None: _seed_state(mock_state) diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index 2828d4e5fb7..5b150bf497f 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -1026,9 +1026,7 @@ async def test_agent_executor_build_input_text_includes_arguments_only(self, moc assert "AttemptedResolutionSteps: Restarted the printer twice." in input_text @_requires_powerfx - async def test_agent_executor_build_input_text_combines_arguments_and_messages( - self, mock_context, mock_state - ): + async def test_agent_executor_build_input_text_combines_arguments_and_messages(self, mock_context, mock_state): """input.arguments are kept alongside explicit messages (#7902).""" from agent_framework_declarative._workflows._executors_agents import ( InvokeAzureAgentExecutor, @@ -1050,9 +1048,7 @@ async def test_agent_executor_build_input_text_combines_arguments_and_messages( assert input_text == "IssueDescription: Printer jammed\nPlease help with this ticket." @_requires_powerfx - async def test_agent_executor_build_input_text_evaluates_argument_expressions( - self, mock_context, mock_state - ): + async def test_agent_executor_build_input_text_evaluates_argument_expressions(self, mock_context, mock_state): """Argument values that are expressions are evaluated before formatting (#7902).""" from agent_framework_declarative._workflows._executors_agents import ( InvokeAzureAgentExecutor, diff --git a/python/packages/declarative/tests/test_graph_executors.py b/python/packages/declarative/tests/test_graph_executors.py index a6e45c5a662..d01ab1c744c 100644 --- a/python/packages/declarative/tests/test_graph_executors.py +++ b/python/packages/declarative/tests/test_graph_executors.py @@ -491,7 +491,7 @@ async def run( agent = _StrictAgent() run_kwargs_bag = { - "function_invocation_kwargs": {"forwarded_props": {"a": 1}}, + "function_invocation_kwargs": {"__global__": {"forwarded_props": {"a": 1}}}, "_raw_function_invocation_kwargs": {"forwarded_props": {"a": 1}}, } mock_context.get_state = MagicMock( @@ -517,11 +517,8 @@ async def run( auto_send=False, ) - assert agent.received_options is not None - # The full bag still reaches tool forwarding, internal copies included. - forwarded = agent.received_options["additional_function_arguments"] - assert forwarded["_raw_function_invocation_kwargs"] == {"forwarded_props": {"a": 1}} - assert forwarded["function_invocation_kwargs"] == {"forwarded_props": {"a": 1}} + assert agent.received_options is None + assert agent.received_public_kwargs["function_invocation_kwargs"] == {"forwarded_props": {"a": 1}} class TestHumanInputExecutors: @@ -1821,6 +1818,113 @@ def test_eval_passes_through_plain_strings_without_engine(self): class TestExecutorKwargsForwarding: """Workflow run kwargs should be forwarded through executor agent invocations.""" + @pytest.mark.parametrize("legacy_state", [False, True]) + async def test_client_runtime_buckets_never_reach_functions_or_mcp(self, legacy_state: bool) -> None: + from agent_framework import ( + Agent, + BaseChatClient, + ChatResponse, + Content, + FunctionInvocationContext, + FunctionInvocationLayer, + MCPStreamableHTTPTool, + Message, + tool, + ) + from agent_framework._workflows._const import RESOLVED_WORKFLOW_RUN_KWARGS_KEY + from mcp import types + from mcp.client.session import ClientSession + + from agent_framework_declarative._workflows import ExternalInputResponse + + class RecordingClient(FunctionInvocationLayer, BaseChatClient): + def __init__(self): + super().__init__() + self.calls: list[dict[str, Any]] = [] + + async def _inner_get_response(self, *, messages, stream, options, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return ChatResponse( + messages=Message( + "assistant", + contents=[ + Content.from_function_call(call_id="local", name="capture", arguments={}), + Content.from_function_call(call_id="remote", name="record", arguments={}), + ], + ) + ) + return ChatResponse(messages=Message("assistant", "done")) + + contexts: list[dict[str, Any]] = [] + + @tool + def capture(ctx: FunctionInvocationContext) -> str: + """Record runtime context.""" + contexts.append(dict(ctx.kwargs)) + return "ok" + + session = MagicMock(spec=ClientSession) + session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="record", + inputSchema={ + "type": "object", + "properties": {"client_kwargs": {"type": "object"}, "tool_marker": {"type": "string"}}, + }, + ) + ] + ) + ) + session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="ok")]) + ) + mcp = MCPStreamableHTTPTool(name="records", url="https://mcp.example/api", session=session, load_prompts=False) + mcp.is_connected = True + await mcp.load_tools() + clients = [RecordingClient(), RecordingClient()] + agents = { + name: Agent(client=client, tools=[capture, mcp]) + for name, client in zip(("first", "second"), clients, strict=True) + } + workflow = DeclarativeWorkflowBuilder( + { + "name": "runtime_buckets", + "actions": [ + { + "kind": "RequestExternalInput", + "id": "pause", + "prompt": "Continue?", + "variable": "Local.answer", + }, + *[{"kind": "InvokeAzureAgent", "id": name, "agent": name, "input": "hello"} for name in agents], + ], + }, + agents=agents, + ).build() + paused = await workflow.run( + ActionTrigger(), + client_kwargs={name: {"extra_headers": {"X-Client-Context": name}} for name in agents}, + function_invocation_kwargs={name: {"tool_marker": name} for name in agents}, + ) + if legacy_state: + workflow._runner.state.delete(RESOLVED_WORKFLOW_RUN_KWARGS_KEY) + workflow._runner.state.commit() + [request] = paused.get_request_info_events() + await workflow.run(responses={request.request_id: ExternalInputResponse(user_input="yes")}) + + for name, client in zip(agents, clients, strict=True): + assert client.calls + assert all(call["extra_headers"] == {"X-Client-Context": name} for call in client.calls) + assert [ctx["tool_marker"] for ctx in contexts] == ["first", "second"] + assert all("client_kwargs" not in ctx and "_raw_client_kwargs" not in ctx for ctx in contexts) + assert [call.kwargs["arguments"] for call in session.call_tool.call_args_list] == [ + {"tool_marker": "first"}, + {"tool_marker": "second"}, + ] + @pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) @pytest.mark.parametrize( ("executor_ids", "invocation_kwargs", "expected"), @@ -1880,7 +1984,7 @@ async def test_workflow_run_resolves_executor_kwargs( for agent, expected_kwargs in zip(agents.values(), expected, strict=True): call_kwargs = agent.run.call_args.kwargs assert call_kwargs[kwargs_channel] == expected_kwargs - assert call_kwargs["options"]["additional_function_arguments"] == {kwargs_channel: expected_kwargs} + assert call_kwargs["options"] is None assert "_raw_function_invocation_kwargs" not in call_kwargs assert "_raw_client_kwargs" not in call_kwargs @@ -1892,7 +1996,7 @@ async def test_workflow_run_kwargs_survive_file_checkpoint_restore( kwargs_channel: str, legacy_checkpoint: bool, ) -> None: - """New checkpoints resolve kwargs while legacy checkpoints keep historical forwarding.""" + """Both current and legacy checkpoints resolve kwargs for the receiving executor.""" from agent_framework import FileCheckpointStorage from agent_framework._workflows._const import ( RESOLVED_WORKFLOW_RUN_KWARGS_KEY, @@ -1977,23 +2081,10 @@ def build_workflow() -> tuple[Any, Any]: ) call_kwargs = resumed_agent.run.call_args.kwargs - if legacy_checkpoint: - raw_key = ( - "_raw_function_invocation_kwargs" - if kwargs_channel == "function_invocation_kwargs" - else "_raw_client_kwargs" - ) - expected_run_kwargs = {kwargs_channel: invocation_kwargs, raw_key: invocation_kwargs} - assert call_kwargs[kwargs_channel] == invocation_kwargs - # The internal raw copy stays routing state for nested executors; - # it is not a public Agent.run parameter (#8413). - assert raw_key not in call_kwargs - else: - expected_run_kwargs = {kwargs_channel: {"shared": "G", "specific": "S"}} - assert call_kwargs[kwargs_channel] == {"shared": "G", "specific": "S"} - assert "_raw_function_invocation_kwargs" not in call_kwargs - assert "_raw_client_kwargs" not in call_kwargs - assert call_kwargs["options"]["additional_function_arguments"] == expected_run_kwargs + assert call_kwargs[kwargs_channel] == {"shared": "G", "specific": "S"} + assert "_raw_function_invocation_kwargs" not in call_kwargs + assert "_raw_client_kwargs" not in call_kwargs + assert call_kwargs["options"] is None @pytest.mark.asyncio async def test_invoke_agent_forwards_kwargs(self): @@ -2020,7 +2111,7 @@ def mock_set(key, value): # Store kwargs in state like Workflow.run() does test_kwargs = {"user_token": "abc123", "service_config": {"endpoint": "http://test"}} - state_data[WORKFLOW_RUN_KWARGS_KEY] = test_kwargs + state_data[WORKFLOW_RUN_KWARGS_KEY] = {"function_invocation_kwargs": {"__global__": test_kwargs}} # Initialize declarative state dws = DeclarativeWorkflowState(mock_state) @@ -2059,13 +2150,8 @@ def mock_set(key, value): mock_agent.run.assert_called_once() call_kwargs = mock_agent.run.call_args - # Check options contains additional_function_arguments - assert "options" in call_kwargs.kwargs - assert call_kwargs.kwargs["options"]["additional_function_arguments"] == test_kwargs - - # Check direct kwargs were passed - assert call_kwargs.kwargs.get("user_token") == "abc123" - assert call_kwargs.kwargs.get("service_config") == {"endpoint": "http://test"} + assert call_kwargs.kwargs["options"] is None + assert call_kwargs.kwargs["function_invocation_kwargs"] == test_kwargs @pytest.mark.asyncio async def test_invoke_agent_merges_caller_options(self): @@ -2091,8 +2177,8 @@ def mock_set(key, value): # Include 'options' in run_kwargs to test merge behavior test_kwargs = { - "user_token": "abc123", - "options": {"temperature": 0.5}, + "function_invocation_kwargs": {"__global__": {"user_token": "abc123"}}, + "options": {"temperature": 0.5, "additional_function_arguments": {"caller_value": "preserved"}}, } state_data[WORKFLOW_RUN_KWARGS_KEY] = test_kwargs @@ -2129,10 +2215,8 @@ def mock_set(key, value): mock_agent.run.assert_called_once() call_kwargs = mock_agent.run.call_args - # Caller options should be merged with additional_function_arguments - merged_options = call_kwargs.kwargs["options"] - assert merged_options["temperature"] == 0.5 - assert "additional_function_arguments" in merged_options - - # Direct kwargs should be passed without 'options' (no duplicate keyword) - assert call_kwargs.kwargs.get("user_token") == "abc123" + assert call_kwargs.kwargs["options"] == { + "temperature": 0.5, + "additional_function_arguments": {"caller_value": "preserved"}, + } + assert call_kwargs.kwargs["function_invocation_kwargs"] == {"user_token": "abc123"} diff --git a/python/packages/declarative/tests/test_invoke_mcp_tool_executor.py b/python/packages/declarative/tests/test_invoke_mcp_tool_executor.py index 4a6bde1bbbd..28b3a1a0d67 100644 --- a/python/packages/declarative/tests/test_invoke_mcp_tool_executor.py +++ b/python/packages/declarative/tests/test_invoke_mcp_tool_executor.py @@ -345,11 +345,7 @@ def mock_state(): # type: ignore[no-untyped-def] state._data = {} def _get(key: str, default: Any = None) -> Any: - if key not in state._data: - if default is not None: - return default - raise KeyError(key) - return state._data[key] + return state._data.get(key, default) def _set(key: str, value: Any) -> None: state._data[key] = value @@ -399,6 +395,90 @@ def _seed_state(mock_state) -> None: # type: ignore[no-untyped-def] class TestApprovalFlow: + @pytest.mark.parametrize("restore", [False, True]) + @pytest.mark.parametrize("change_context", [False, True]) + @pytest.mark.asyncio + async def test_auth_context_binding_survives_workflow_resume(self, restore: bool, change_context: bool) -> None: + from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder + + from agent_framework_declarative._workflows import ( + ActionTrigger, + ExternalInputResponse, + InvokeMcpToolActionExecutor, + MCPToolApprovalRequest, + RequestExternalInputExecutor, + SetValueExecutor, + ToolApprovalResponse, + ) + from agent_framework_declarative._workflows._declarative_base import DeclarativeEnvConfig + from agent_framework_declarative._workflows._executors_mcp import _HEADER_BINDING_KEY + + storage = InMemoryCheckpointStorage() + handler = StubMcpHandler(_ok()) + config = DeclarativeEnvConfig(values={"FIRST": "context-first", "SECOND": "context-second"}) + + def build(): + start = SetValueExecutor({"kind": "SetValue", "id": "start", "path": "Local.selector", "value": "first"}) + change = RequestExternalInputExecutor({ + "kind": "RequestExternalInput", + "id": "change", + "prompt": "Select context", + "variable": "Local.selector", + }) + invoke = InvokeMcpToolActionExecutor( + _action( + require_approval=True, + arguments={"q": "reviewed"}, + headers={"Authorization": '=If(Local.selector = "first", Env.FIRST, Env.SECOND)'}, + ), + mcp_tool_handler=handler, + ) + invoke.set_declarative_env_config(config) + return ( + WorkflowBuilder(name="approval_context", start_executor=start, checkpoint_storage=storage) + .add_fan_out_edges(start, [change, invoke]) + .build() + ) + + workflow = build() + paused = await workflow.run(ActionTrigger()) + requests = paused.get_request_info_events() + approval = next(event for event in requests if isinstance(event.data, MCPToolApprovalRequest)) + change_request = next(event for event in requests if event is not approval) + selector = "second" if change_context else "first" + await workflow.run(responses={change_request.request_id: ExternalInputResponse(user_input=selector)}) + assert handler.call_count == 0 + + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + assert "context-first" not in repr(checkpoints) + assert "context-second" not in repr(checkpoints) + assert "context-first" not in repr(approval.data) + assert "context-second" not in repr(approval.data) + checkpoint = max(checkpoints, key=lambda item: item.timestamp) + assert checkpoint.state[_HEADER_BINDING_KEY] not in repr(approval.data) + if restore: + workflow = build() + await workflow.run(checkpoint_id=checkpoint.checkpoint_id) + + resumed = await workflow.run(responses={approval.request_id: ToolApprovalResponse(approved=True)}) + if not change_context: + assert handler.call_count == 1 + assert not resumed.get_request_info_events() + assert handler.last_invocation is not None + assert handler.last_invocation.headers == {"Authorization": "context-first"} + return + assert handler.call_count == 0 + [replacement] = resumed.get_request_info_events() + assert replacement.request_id != approval.request_id + assert replacement.data.arguments == {"q": "reviewed"} + assert "context-second" not in repr(replacement.data) + + await workflow.run(responses={replacement.request_id: ToolApprovalResponse(approved=True)}) + assert handler.call_count == 1 + assert handler.last_invocation is not None + assert handler.last_invocation.headers == {"Authorization": "context-second"} + assert handler.last_invocation.arguments == {"q": "reviewed"} + @pytest.mark.asyncio async def test_approval_required_emits_request_and_yields(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def] from agent_framework_declarative._workflows._declarative_base import ActionTrigger @@ -440,10 +520,9 @@ async def test_approval_required_emits_request_and_yields(self, mock_state, mock @pytest.mark.asyncio async def test_approval_response_approved_invokes_handler(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def] - from agent_framework_declarative._workflows import ActionComplete, ToolApprovalResponse + from agent_framework_declarative._workflows import ActionComplete, ActionTrigger, ToolApprovalResponse from agent_framework_declarative._workflows._executors_mcp import ( InvokeMcpToolActionExecutor, - MCPToolApprovalRequest, ) _seed_state(mock_state) @@ -451,22 +530,15 @@ async def test_approval_response_approved_invokes_handler(self, mock_state, mock executor = InvokeMcpToolActionExecutor( _action( require_approval=True, + arguments={"q": "x"}, headers={"Authorization": "Bearer tk"}, output={"result": "Local.Result"}, ), mcp_tool_handler=handler, ) - await executor.handle_approval_response( - MCPToolApprovalRequest( - request_id="req-1", - tool_name="search", - server_url="https://mcp.example/api", - server_label=None, - arguments={"q": "x"}, - ), - ToolApprovalResponse(approved=True), - mock_context, - ) + await executor.handle_action(ActionTrigger(), mock_context) + request = mock_context.request_info.call_args.args[0] + await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context) assert handler.call_count == 1 inv = handler.last_invocation From 624ae011e3cd1769aa18985e11075cf70528f219 Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 22 Sep 2026 12:51:13 +0000 Subject: [PATCH 04/42] Python: skip empty and whitespace-only instructions in prepend (#8524) Only None short-circuited before; an instructions value of "" (the usual unset-options default) still injected a system message with empty content ahead of the conversation. Empty and all-whitespace entries are dropped, and a real instruction prepends verbatim. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg --- .../packages/core/agent_framework/_types.py | 7 +++++++ python/packages/core/tests/test_types.py | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 python/packages/core/tests/test_types.py diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index d2ed7de92c1..de9d6ac09b0 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2011,6 +2011,13 @@ def prepend_instructions_to_messages( if isinstance(instructions, str): instructions = [instructions] + # An empty instruction list (or all-empty strings) adds nothing; without + # this a caller that passes an unset options default of "" gets a + # contentless system message injected ahead of the real conversation. + instructions = [part for part in instructions if part.strip()] + if not instructions: + return messages + # Skip instructions that are already present as the leading messages with the # same role and text. This prevents duplicate system messages when # instructions are injected by multiple layers (e.g. Agent + chat client). diff --git a/python/packages/core/tests/test_types.py b/python/packages/core/tests/test_types.py new file mode 100644 index 00000000000..0622fb48e6d --- /dev/null +++ b/python/packages/core/tests/test_types.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft. All rights reserved. + +class TestPrependInstructionsEmpty: + def test_empty_string_instructions_add_no_message(self) -> None: + """An unset "" instruction must not inject a contentless system message.""" + from agent_framework import Message, prepend_instructions_to_messages + + messages = [Message(role="user", contents=["hi"])] + out = prepend_instructions_to_messages(messages, "") + assert [(m.role, [getattr(c, "text", c) for c in m.contents]) for m in out] == [("user", ["hi"])] + + def test_whitespace_only_instructions_add_no_message(self) -> None: + from agent_framework import prepend_instructions_to_messages + + assert prepend_instructions_to_messages([], ["", " "]) == [] + + def test_real_instructions_still_prepend_verbatim(self) -> None: + from agent_framework import prepend_instructions_to_messages + + out = prepend_instructions_to_messages([], [" real "]) + assert [(m.role, [getattr(c, "text", c) for c in m.contents]) for m in out] == [("system", [" real "])] From bb53fe15a8375426a98763bd43eed64762af3f07 Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 22 Sep 2026 13:11:47 +0000 Subject: [PATCH 05/42] test(ollama): narrow pytest.raises blocks to wrap only get_response in error tests (#8611) In test_cmc_with_invalid_data_content_media_type and test_cmc_with_invalid_content_type, the entire test setup including message creation and client instantiation was enclosed inside the with pytest.raises block. If any setup statement failed with ChatClientInvalidRequestException, the test would pass falsely. Moving the fixture setup outside the context manager ensures that only the actual get_response call is asserted to raise the exception. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../ollama/tests/test_ollama_chat_client.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index a8245bf3ef0..e393e808980 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -639,19 +639,19 @@ async def test_cmc_with_invalid_data_content_media_type( chat_history: list[Message], mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: - with pytest.raises(ChatClientInvalidRequestException): - mock_chat.return_value = mock_streaming_chat_completion_response - # Remote Uris are not supported by Ollama client - chat_history.append( - Message( - contents=[Content.from_uri(uri="data:audio/mp3;base64,xyz", media_type="audio/mp3")], - role="user", - ) + mock_chat.return_value = mock_streaming_chat_completion_response + # Remote Uris are not supported by Ollama client + chat_history.append( + Message( + contents=[Content.from_uri(uri="data:audio/mp3;base64,xyz", media_type="audio/mp3")], + role="user", ) + ) - ollama_client = OllamaChatClient() - ollama_client.client.chat = AsyncMock(return_value=mock_streaming_chat_completion_response) # type: ignore[method-assign] + ollama_client = OllamaChatClient() + ollama_client.client.chat = AsyncMock(return_value=mock_streaming_chat_completion_response) # type: ignore[method-assign] + with pytest.raises(ChatClientInvalidRequestException): await ollama_client.get_response(messages=chat_history) @@ -662,18 +662,18 @@ async def test_cmc_with_invalid_content_type( chat_history: list[Message], mock_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: - with pytest.raises(ChatClientInvalidRequestException): - mock_chat.return_value = mock_chat_completion_response - # Remote Uris are not supported by Ollama client - chat_history.append( - Message( - contents=[Content.from_uri(uri="http://example.com/image.png", media_type="image/png")], - role="user", - ) + mock_chat.return_value = mock_chat_completion_response + # Remote Uris are not supported by Ollama client + chat_history.append( + Message( + contents=[Content.from_uri(uri="http://example.com/image.png", media_type="image/png")], + role="user", ) + ) - ollama_client = OllamaChatClient() + ollama_client = OllamaChatClient() + with pytest.raises(ChatClientInvalidRequestException): await ollama_client.get_response(messages=chat_history) From 7b626709dbce2c170030c388cea9e611738e5be5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 22 Sep 2026 13:57:53 +0000 Subject: [PATCH 06/42] ci: removes workflow based PR limit to use GitHub native feature (#8603) --- .github/scripts/pr_limit_moderation.js | 181 ------------ .github/tests/test_pr_limit_moderation.js | 341 ---------------------- .github/workflows/limit-community-prs.yml | 122 -------- 3 files changed, 644 deletions(-) delete mode 100644 .github/scripts/pr_limit_moderation.js delete mode 100644 .github/tests/test_pr_limit_moderation.js delete mode 100644 .github/workflows/limit-community-prs.yml diff --git a/.github/scripts/pr_limit_moderation.js b/.github/scripts/pr_limit_moderation.js deleted file mode 100644 index 6cb23e053ad..00000000000 --- a/.github/scripts/pr_limit_moderation.js +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -function getPullRequest(context) { - const pullRequest = context.payload.pull_request; - if (!pullRequest?.number || !pullRequest.user?.login) { - throw new Error('This script must be run from a pull_request_target event.'); - } - - return { - author: pullRequest.user.login, - authorType: pullRequest.user.type, - labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [], - number: pullRequest.number, - }; -} - -async function ensureLabel({ github, owner, repo, labelName }) { - try { - await github.rest.issues.getLabel({ - owner, - repo, - name: labelName, - }); - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner, - repo, - name: labelName, - color: 'd93f0b', - description: 'Community author has exceeded the open pull request limit.', - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } -} - -function hasLabel(labels, labelName) { - if (!labelName) { - return false; - } - - return labels.some((label) => label.toLowerCase() === labelName.toLowerCase()); -} - -function isDependabotAuthor({ author, authorType }) { - return authorType === 'Bot' && author.toLowerCase() === 'dependabot[bot]'; -} - -function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) { - return [ - `Thank you for your contribution, @${author}.`, - '', - `To keep the review queue manageable, we currently limit community contributors to ${maxOpenPrs} ` - + `open pull requests at a time. This PR would put you at ${openPrCount} open pull requests, ` - + 'so we are closing it automatically.', - '', - 'Please focus on getting your existing PRs reviewed, merged, or closed before opening another one. ' - + `If a maintainer asked you to open this PR, they can apply the \`${exemptLabelName}\` label and reopen it.`, - ].join('\n'); -} - -async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }) { - const openPullRequests = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: 'open', - per_page: 100, - }); - - const authorOpenPullRequestNumbers = openPullRequests - .filter((pullRequest) => pullRequest.user?.login === author) - .map((pullRequest) => pullRequest.number); - const currentPrIsOpen = authorOpenPullRequestNumbers.includes(pullRequestNumber); - const existingOpenPrCount = currentPrIsOpen - ? authorOpenPullRequestNumbers.length - 1 - : authorOpenPullRequestNumbers.length; - - return existingOpenPrCount + 1; -} - -async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) { - const { owner, repo } = context.repo; - const { author, authorType, labels, number } = getPullRequest(context); - - if (isDependabotAuthor({ author, authorType })) { - core.info(`Author ${author} is Dependabot; skipping open PR limit enforcement.`); - return { - author, - closed: false, - dependabotExempt: true, - openPrCount: null, - }; - } - - if (hasLabel(labels, exemptLabelName)) { - core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`); - return { - author, - closed: false, - exempt: true, - openPrCount: null, - }; - } - - const openPrCount = await getOpenPrCount({ - github, - owner, - repo, - author, - pullRequestNumber: number, - }); - - if (openPrCount <= maxOpenPrs) { - core.info( - `${author} has ${openPrCount} open pull request(s), which is within the limit of ${maxOpenPrs}.`, - ); - return { - author, - closed: false, - openPrCount, - }; - } - - await ensureLabel({ - github, - owner, - repo, - labelName, - }); - - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: number, - labels: [labelName], - }); - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: number, - body: buildLimitMessage({ - author, - exemptLabelName, - maxOpenPrs, - openPrCount, - }), - }); - - await github.rest.pulls.update({ - owner, - repo, - pull_number: number, - state: 'closed', - }); - - core.info( - `${author} has ${openPrCount} open pull request(s), which exceeds the limit of ${maxOpenPrs}. ` - + `Closed PR #${number}.`, - ); - - return { - author, - closed: true, - openPrCount, - }; -} - -module.exports = { - buildLimitMessage, - enforcePrLimit, - getOpenPrCount, -}; diff --git a/.github/tests/test_pr_limit_moderation.js b/.github/tests/test_pr_limit_moderation.js deleted file mode 100644 index 5f0c8c865ab..00000000000 --- a/.github/tests/test_pr_limit_moderation.js +++ /dev/null @@ -1,341 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -/** - * Tests for pr_limit_moderation.js. - * - * Run with: node --test .github/tests/test_pr_limit_moderation.js - */ - -const { describe, it } = require('node:test'); -const assert = require('node:assert/strict'); - -const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js'); - - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) { - return { - repo: { - owner: 'microsoft', - repo: 'agent-framework', - }, - payload: { - pull_request: { - number, - labels: labels.map((name) => ({ name })), - user: { - login: author, - type: authorType, - }, - }, - }, - }; -} - -function createCore() { - const messages = []; - return { - messages, - info(message) { - messages.push(message); - }, - }; -} - -function createGithub({ - itemNumbers, - labelExists = true, - pullRequests = createPullRequestPage({ numbers: itemNumbers }), -}) { - const calls = []; - - return { - calls, - async paginate(method, params) { - calls.push({ api: 'paginate', method, params }); - return pullRequests; - }, - rest: { - issues: { - async getLabel(params) { - calls.push({ api: 'issues.getLabel', params }); - if (!labelExists) { - const error = new Error('Not Found'); - error.status = 404; - throw error; - } - return { data: { name: params.name } }; - }, - async createLabel(params) { - calls.push({ api: 'issues.createLabel', params }); - return { data: { name: params.name } }; - }, - async addLabels(params) { - calls.push({ api: 'issues.addLabels', params }); - return { data: [] }; - }, - async createComment(params) { - calls.push({ api: 'issues.createComment', params }); - return { data: { id: 1 } }; - }, - }, - pulls: { - async list(params) { - calls.push({ api: 'pulls.list', params }); - return { data: pullRequests }; - }, - async update(params) { - calls.push({ api: 'pulls.update', params }); - return { data: { state: params.state } }; - }, - }, - }, - }; -} - -function createPullRequestPage({ author = 'community-user', numbers }) { - return numbers.map((number) => ({ - number, - user: { - login: author, - }, - })); -} - - -// --------------------------------------------------------------------------- -// PR limit enforcement -// --------------------------------------------------------------------------- - -describe('PR limit enforcement', () => { - it('does not close the PR when the author is at the open PR limit', async () => { - const github = createGithub({ - itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 123], - }); - - const result = await enforcePrLimit({ - github, - context: createContext(), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, false); - assert.equal(result.openPrCount, 10); - assert.deepEqual( - github.calls.map((call) => call.api), - ['paginate'], - ); - }); - - it('counts the new PR when the pull list includes it', async () => { - const github = createGithub({ - itemNumbers: [123, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - }); - - const result = await enforcePrLimit({ - github, - context: createContext(), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, true); - assert.equal(result.openPrCount, 11); - assert.deepEqual( - github.calls.map((call) => call.api), - [ - 'paginate', - 'issues.getLabel', - 'issues.addLabels', - 'issues.createComment', - 'pulls.update', - ], - ); - }); - - it('counts the current PR on top of existing open PRs', async () => { - const github = createGithub({ - itemNumbers: [123, ...Array.from({ length: 24 }, (_, index) => index + 1)], - pullRequests: createPullRequestPage({ - numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)], - }), - }); - - const result = await enforcePrLimit({ - github, - context: createContext(), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, true); - assert.equal(result.openPrCount, 26); - const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body; - assert.match(comment, /This PR would put you at 26 open pull requests/); - }); - - it('creates the label when it does not already exist', async () => { - const github = createGithub({ - itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123], - labelExists: false, - }); - - const result = await enforcePrLimit({ - github, - context: createContext(), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, true); - assert.deepEqual( - github.calls.map((call) => call.api), - [ - 'paginate', - 'issues.getLabel', - 'issues.createLabel', - 'issues.addLabels', - 'issues.createComment', - 'pulls.update', - ], - ); - assert.equal( - github.calls.find((call) => call.api === 'issues.createLabel').params.name, - 'too-many-prs', - ); - }); - - it('tolerates a 422 race when creating the label', async () => { - const github = createGithub({ - itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123], - labelExists: false, - }); - github.rest.issues.createLabel = async (params) => { - github.calls.push({ api: 'issues.createLabel', params }); - const error = new Error('Validation Failed'); - error.status = 422; - throw error; - }; - - const result = await enforcePrLimit({ - github, - context: createContext(), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, true); - assert.deepEqual( - github.calls.map((call) => call.api), - [ - 'paginate', - 'issues.getLabel', - 'issues.createLabel', - 'issues.addLabels', - 'issues.createComment', - 'pulls.update', - ], - ); - }); - - it('uses a diplomatic close message with the configured limit', async () => { - const github = createGithub({ - itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123], - pullRequests: createPullRequestPage({ - author: 'octo-contributor', - numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123], - }), - }); - - await enforcePrLimit({ - github, - context: createContext({ author: 'octo-contributor' }), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body; - assert.match(comment, /Thank you for your contribution/); - assert.match(comment, /limit community contributors to 10 open pull requests/); - assert.match(comment, /@octo-contributor/); - assert.match(comment, /`pr-limit-exempt` label and reopen/); - }); - - it('does not close an exempt PR when it is reopened', async () => { - const github = createGithub({ - itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123], - }); - - const result = await enforcePrLimit({ - github, - context: createContext({ labels: ['PR-LIMIT-EXEMPT'] }), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, false); - assert.equal(result.exempt, true); - assert.equal(result.openPrCount, null); - assert.deepEqual(github.calls, []); - }); - - it('does not close Dependabot PRs', async () => { - const github = createGithub({ - itemNumbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)], - pullRequests: createPullRequestPage({ - author: 'dependabot[bot]', - numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)], - }), - }); - - const result = await enforcePrLimit({ - github, - context: createContext({ author: 'dependabot[bot]', authorType: 'Bot' }), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, false); - assert.equal(result.dependabotExempt, true); - assert.equal(result.openPrCount, null); - assert.deepEqual(github.calls, []); - }); - - it('counts the current PR when the author has more than one page of open PRs', async () => { - const github = createGithub({ - itemNumbers: [123, ...Array.from({ length: 100 }, (_, index) => index + 1)], - }); - - const result = await enforcePrLimit({ - github, - context: createContext({ number: 123 }), - core: createCore(), - exemptLabelName: 'pr-limit-exempt', - maxOpenPrs: 10, - labelName: 'too-many-prs', - }); - - assert.equal(result.closed, true); - assert.equal(result.openPrCount, 101); - }); -}); diff --git a/.github/workflows/limit-community-prs.yml b/.github/workflows/limit-community-prs.yml deleted file mode 100644 index 12e40018612..00000000000 --- a/.github/workflows/limit-community-prs.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: Limit community pull requests - -on: - pull_request_target: - types: [opened, reopened] - -permissions: - contents: read - id-token: write - issues: write - pull-requests: write - -concurrency: - group: pr-limit-${{ github.repository }}-${{ github.event.pull_request.user.login }} - cancel-in-progress: false - -env: - MAX_OPEN_PULL_REQUESTS: '10' - PR_LIMIT_EXEMPT_LABEL: pr-limit-exempt - TOO_MANY_PRS_LABEL: too-many-prs - -jobs: - team_check: - runs-on: ubuntu-latest - environment: github-app-auth - outputs: - is_team_member: ${{ steps.check.outputs.is_team_member }} - steps: - - name: Checkout scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - sparse-checkout: | - .github/actions/github-app-token - .github/scripts - fetch-depth: 1 - persist-credentials: false - - - name: Get GitHub automation token - id: github-auth - uses: ./.github/actions/github-app-token - with: - mode: ${{ vars.GH_APP_AUTH_MODE }} - azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }} - azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }} - azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }} - key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }} - key-name: ${{ secrets.GH_APP_KEY_NAME }} - github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }} - github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} - repository: ${{ github.repository }} - fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} - - - name: Check PR author team membership - id: check - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} - PR_NUMBER: ${{ github.event.pull_request.number }} - with: - github-token: ${{ steps.github-auth.outputs.token }} - script: | - const checkTeamMembership = require('./.github/scripts/check_team_membership.js'); - const { author, isTeamMember } = await checkTeamMembership({ - github, - context, - core, - teamSlug: process.env.TEAM_NAME, - issueNumber: process.env.PR_NUMBER, - }); - core.setOutput('is_team_member', isTeamMember ? 'true' : 'false'); - if (isTeamMember) { - core.info(`Author ${author} is a team member; skipping open PR limit.`); - } else { - core.info(`Author ${author} is not a team member; checking open PR limit.`); - } - - limit_open_prs: - runs-on: ubuntu-latest - environment: github-app-auth - needs: team_check - if: ${{ needs.team_check.outputs.is_team_member == 'false' }} - steps: - - name: Checkout scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - sparse-checkout: | - .github/actions/github-app-token - .github/scripts - fetch-depth: 1 - persist-credentials: false - - - name: Get GitHub automation token - id: github-auth - uses: ./.github/actions/github-app-token - with: - mode: ${{ vars.GH_APP_AUTH_MODE }} - azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }} - azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }} - azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }} - key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }} - key-name: ${{ secrets.GH_APP_KEY_NAME }} - github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }} - github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} - repository: ${{ github.repository }} - fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} - - - name: Enforce open PR limit - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.github-auth.outputs.token }} - script: | - const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js'); - await enforcePrLimit({ - github, - context, - core, - exemptLabelName: process.env.PR_LIMIT_EXEMPT_LABEL, - maxOpenPrs: Number.parseInt(process.env.MAX_OPEN_PULL_REQUESTS, 10), - labelName: process.env.TOO_MANY_PRS_LABEL, - }); From 02bd1ba1de43ca8d1206a5c61e86ffc0d6f44b12 Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 22 Sep 2026 14:00:44 +0000 Subject: [PATCH 07/42] fix(ag-ui): allow text events when response_format is a json schema dictionary (#8604) When response_format is configured as a json schema dict rather than a Pydantic BaseModel, skip_text was previously set to True, which suppressed text content events. This change ensures skip_text is only enabled for Pydantic models whose fields map directly to state or message fields. Non-model formats keep emitting text content events as expected. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 16 +++++---- .../tests/ag_ui/test_structured_output.py | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index a9360cef187..9fbb15f8d65 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -2954,14 +2954,18 @@ async def _run_agent_stream( snapshot_seed_messages, protected_tool_call_ids=protected_tool_call_ids, ) - # Check for structured output mode (skip text content) + # Check for structured output mode (skip text content only for Pydantic models whose fields map to state/message) skip_text = False - response_format: type[Any] | None = None + response_format: Any | None = None + is_pydantic_response_format = False default_options = getattr(agent, "default_options", None) if isinstance(default_options, dict): + from pydantic import BaseModel + typed_default_options = cast(dict[str, Any], default_options) - response_format = cast(type[Any] | None, typed_default_options.get("response_format")) - skip_text = response_format is not None + response_format = typed_default_options.get("response_format") + is_pydantic_response_format = isinstance(response_format, type) and issubclass(response_format, BaseModel) + skip_text = is_pydantic_response_format # Handle empty messages (emit RunStarted immediately since no agent response) if not messages and not only_cancelled_resume: @@ -3576,8 +3580,8 @@ async def _run_agent_stream( from agent_framework import AgentResponse from pydantic import BaseModel - if not (isinstance(response_format, type) and issubclass(response_format, BaseModel)): - logger.warning("Skipping structured output parsing: response_format is not a Pydantic model type.") + if not is_pydantic_response_format: + logger.debug("Skipping structured output parsing: response_format is not a Pydantic model type.") else: logger.info(f"Processing structured output, update count: {len(all_updates)}") final_response = AgentResponse.from_updates(all_updates, output_format_type=response_format) diff --git a/python/packages/ag-ui/tests/ag_ui/test_structured_output.py b/python/packages/ag-ui/tests/ag_ui/test_structured_output.py index df612ef68c2..db7cf09ecc9 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_structured_output.py +++ b/python/packages/ag-ui/tests/ag_ui/test_structured_output.py @@ -261,3 +261,37 @@ async def stream_fn( # Should only have start and end events assert len(events) == 2 # RunStarted, RunFinished + + +async def test_structured_output_with_json_schema_mapping_emits_text( + streaming_chat_client_stub, stream_from_updates_fixture +): + """Test that JSON Schema mapping response_format still emits text content events.""" + from agent_framework.ag_ui import AgentFrameworkAgent + + json_payload = '{"name": "Alice", "age": 30}' + updates = [ChatResponseUpdate(contents=[Content.from_text(text=json_payload)])] + + agent = Agent( + name="extractor", + instructions="Extract person info as JSON.", + client=streaming_chat_client_stub(stream_from_updates_fixture(updates)), + ) + agent.default_options = { + "response_format": { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + "required": ["name", "age"], + } + } + + wrapper = AgentFrameworkAgent(agent=agent) + input_data = {"messages": [{"role": "user", "content": "Extract info for Alice aged 30"}]} + + events: list[Any] = [] + async for event in wrapper.run(input_data): + events.append(event) + + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_events) > 0 + assert text_events[0].delta == json_payload From eb74c8cceba97bc037413dcf25cfcf1aeab77634 Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 22 Sep 2026 14:05:03 +0000 Subject: [PATCH 08/42] fix(core): ensure add_usage_details returns copies and filters non-ints consistently (#8613) In add_usage_details, passing None for one of the arguments previously returned the other argument dictionary directly by reference, and early return copies bypassed boolean and non-integer filtering. Normalizing None operands to empty UsageDetails and routing all combinations through the common merge path ensures returned dictionaries are always new copies and that booleans and non-integer values are consistently skipped and logged. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 17 +++++---- python/packages/core/tests/core/test_types.py | 35 +++++++++++++++++-- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index de9d6ac09b0..9d75a68eb65 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -464,17 +464,20 @@ def add_usage_details(usage1: UsageDetails | None, usage2: UsageDetails | None) combined = add_usage_details(usage1, usage2) # Result: {'input_token_count': 8, 'output_token_count': 16} """ - if usage1 is None: - return usage2 or UsageDetails() - if usage2 is None: - return usage1 + u1 = usage1 or UsageDetails() + u2 = usage2 or UsageDetails() result = UsageDetails() # Combine all keys from both dictionaries - all_keys = set(usage1.keys()) | set(usage2.keys()) + all_keys = set(u1.keys()) | set(u2.keys()) for key in all_keys: - if not isinstance((val1 := usage1.get(key, 0)), (int | None)) or not isinstance( - (val2 := usage2.get(key, 0)), (int | None) + val1 = u1.get(key, 0) + val2 = u2.get(key, 0) + if ( + isinstance(val1, bool) + or isinstance(val2, bool) + or not isinstance(val1, (int, type(None))) + or not isinstance(val2, (int, type(None))) ): logger.warning("Non `int` value found in usage details, skipping.") continue diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index df2f55efb63..a6797b30449 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -808,12 +808,43 @@ def test_usage_details_add_with_none_and_type_errors(): def test_usage_details_add_skips_non_int(): - u1 = cast(UsageDetails, {"input_token_count": 10, "other": "test"}) - u2 = cast(UsageDetails, {"input_token_count": 10, "another": "test"}) + u1 = cast(UsageDetails, {"input_token_count": 10, "other": "test", "flag": True}) + u2 = cast(UsageDetails, {"input_token_count": 10, "another": "test", "flag": 5}) u3 = add_usage_details(u1, u2) assert len(u3.keys()) == 1 assert "input_token_count" in u3 assert u3["input_token_count"] == 20 + assert "flag" not in u3 + + +def test_usage_details_add_with_none_returns_copy(): + u = UsageDetails(input_token_count=1) + v1 = add_usage_details(u, None) + v2 = add_usage_details(None, u) + assert v1 == u + assert v1 is not u + assert v2 == u + assert v2 is not u + + +def test_usage_details_add_with_none_filters_booleans_and_non_ints(): + payload = cast(UsageDetails, {"input_token_count": 5, "flag": True, "text": "skip_me"}) + v1 = add_usage_details(payload, None) + v2 = add_usage_details(None, payload) + assert v1 == {"input_token_count": 5} + assert v1 is not payload + assert "flag" not in v1 + assert "text" not in v1 + assert v2 == {"input_token_count": 5} + assert v2 is not payload + assert "flag" not in v2 + assert "text" not in v2 + + +def test_usage_details_add_both_none_returns_empty(): + result = add_usage_details(None, None) + assert result == {} + assert isinstance(result, dict) # region UserInputRequest and Response From 17b349f2150d5b90649998a32a067db07319649a Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 22 Sep 2026 14:06:15 +0000 Subject: [PATCH 09/42] docs(core): fix positional invocations in detect_media_type_from_base64 docstrings (#8614) The function signature of detect_media_type_from_base64 uses keyword only arguments (*). The code examples in the docstrings of detect_media_type_from_base64 and Content.from_data passed string variables positionally, which raises a TypeError when executed directly. Updating the examples to pass data_str and data_uri keyword arguments ensures the documentation samples run cleanly. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- python/packages/core/agent_framework/_types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 9d75a68eb65..17dba475e3e 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -113,12 +113,12 @@ def detect_media_type_from_base64( # Detect from base64 string base64_data = "iVBORw0KGgo..." - media_type = detect_media_type_from_base64(base64_data) + media_type = detect_media_type_from_base64(data_str=base64_data) # Returns: "image/png" # Works with data URIs too data_uri = "data:image/png;base64,iVBORw0KGgo..." - media_type = detect_media_type_from_base64(data_uri) + media_type = detect_media_type_from_base64(data_uri=data_uri) # Returns: "image/png" """ data: bytes | None = None @@ -717,7 +717,7 @@ def from_data( from agent_framework import detect_media_type_from_base64, Content - media_type = detect_media_type_from_base64(base64_string) + media_type = detect_media_type_from_base64(data_str=base64_string) if media_type is None: raise ValueError("Could not detect media type") data_bytes = base64.b64decode(base64_string) @@ -746,7 +746,7 @@ def from_data( # If you have a base64 string and need to detect media type base64_string = "iVBORw0KGgo..." - media_type = detect_media_type_from_base64(base64_string) + media_type = detect_media_type_from_base64(data_str=base64_string) if media_type is None: raise ValueError("Unknown media type") image_bytes = base64.b64decode(base64_string) From 334bc3176c61fdee0da4d82c5b8a33f736955d0a Mon Sep 17 00:00:00 2001 From: Sunhy <58774225+Shy7777@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:07:52 +0000 Subject: [PATCH 10/42] Python: preserve Redis memory vectorizer data types (#8629) --- .../_context_provider.py | 8 +++- .../tests/test_context_provider_edges.py | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/python/packages/redis/agent_framework_redis/_context_provider.py b/python/packages/redis/agent_framework_redis/_context_provider.py index 57fe4f97fd8..4886731c99f 100644 --- a/python/packages/redis/agent_framework_redis/_context_provider.py +++ b/python/packages/redis/agent_framework_redis/_context_provider.py @@ -12,7 +12,6 @@ import sys from typing import TYPE_CHECKING, Any, ClassVar, Literal -import numpy as np from agent_framework import Message from agent_framework._sessions import AgentSession, ContextProvider, SessionContext from agent_framework._telemetry import mark_feature_used @@ -23,6 +22,7 @@ from redisvl.index import AsyncSearchIndex from redisvl.query import AggregateHybridQuery, TextQuery from redisvl.query.filter import FilterExpression, Tag +from redisvl.redis.utils import array_to_buffer from redisvl.utils.token_escaper import TokenEscaper from redisvl.utils.vectorize import BaseVectorizer @@ -341,7 +341,11 @@ async def _add( text_list, batch_size=len(text_list) ) for i, d in enumerate(prepared): - vec = np.asarray(embeddings[i], dtype=np.float32).tobytes() + # aembed_many returns lists unless as_buffer is enabled. + vec = array_to_buffer( + embeddings[i], # pyright: ignore[reportArgumentType] + dtype=self.redis_vectorizer.dtype, + ) field_name: str = self.vector_field_name d[field_name] = vec diff --git a/python/packages/redis/tests/test_context_provider_edges.py b/python/packages/redis/tests/test_context_provider_edges.py index 36146f52019..ea6287ffe37 100644 --- a/python/packages/redis/tests/test_context_provider_edges.py +++ b/python/packages/redis/tests/test_context_provider_edges.py @@ -9,7 +9,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent_framework import AgentResponse, AgentSession, Message, SessionContext from agent_framework.exceptions import IntegrationInvalidRequestException +from redisvl.redis.utils import buffer_to_array from redisvl.utils.vectorize import BaseVectorizer from agent_framework_redis._context_provider import RedisContextProvider @@ -170,6 +172,43 @@ async def test_add_vectorizes_documents_and_applies_defaults( vectorizer.aembed_many.assert_awaited_once_with(["first", "second"], batch_size=2) +@pytest.mark.parametrize("dtype, expected_size", [("float16", 4), ("float32", 8), ("float64", 16), ("bfloat16", 4)]) +async def test_after_run_stores_vectors_in_configured_dtype( + dtype: str, + expected_size: int, + mock_index: AsyncMock, +) -> None: + vectorizer = MagicMock(spec=BaseVectorizer) + vectorizer.dims = 2 + vectorizer.dtype = dtype + embeddings = [[1.5, -2.25], [3.125, 4.5]] + vectorizer.aembed_many = AsyncMock(return_value=embeddings) + provider = RedisContextProvider( + user_id="user-1", + redis_vectorizer=vectorizer, + vector_field_name="embedding", + redis_index=mock_index, + ) + session = AgentSession(session_id="session-1") + context = SessionContext( + session_id=session.session_id, + input_messages=[Message(role="user", contents=["I prefer tea"])], + ) + context._response = AgentResponse(messages=[Message(role="assistant", contents=["I'll remember that"])]) + + await provider.after_run(agent=MagicMock(), session=session, context=context, state={}) + + vector_field = next(field for field in provider.schema_dict["fields"] if field["name"] == "embedding") + assert vector_field["attrs"]["datatype"] == dtype + mock_index.load.assert_awaited_once() + loaded_docs = mock_index.load.await_args.args[0] + assert [doc["content"] for doc in loaded_docs] == ["I prefer tea", "I'll remember that"] + for doc, expected_embedding in zip(loaded_docs, embeddings, strict=True): + assert len(doc["embedding"]) == expected_size + assert buffer_to_array(doc["embedding"], dtype) == expected_embedding + vectorizer.aembed_many.assert_awaited_once_with(["I prefer tea", "I'll remember that"], batch_size=2) + + async def test_redis_search_requires_non_empty_text( patch_index: MagicMock, # noqa: ARG001 ) -> None: From 13b6ffe9a6d6dc2da8a66a33400ef710bf624066 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 22 Sep 2026 14:08:41 +0000 Subject: [PATCH 11/42] Revert "ci: switches to a dedicated solution for dependabot (#8486)" (#8642) This reverts commit 98a982a147212424d766ed993e6ecda13abe5faf. --- .github/dependabot.yml | 11 ++- dotnet/dependabot/README.md | 9 -- .../agent-framework-dependabot.slnx | 96 ------------------- 3 files changed, 7 insertions(+), 109 deletions(-) delete mode 100644 dotnet/dependabot/README.md delete mode 100644 dotnet/dependabot/agent-framework-dependabot.slnx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3bddc65eab6..824f537f139 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,10 +7,13 @@ version: 2 updates: # Maintain dependencies for nuget - package-ecosystem: "nuget" - # Use the Dependabot-only solution in this folder so NuGet discovery does - # not expand the full developer solution, which includes samples and times - # out before creating update PRs. - directory: "dotnet/dependabot/" + directory: "dotnet/" + # The samples tree contains most of the .NET package manifests and causes + # the NuGet updater to exceed Dependabot's fixed execution timeout. Keep the + # production source, tests, and central package versions in scope so updates + # can be opened again. + exclude-paths: + - "samples/**" schedule: interval: "cron" cronjob: "0 8 * * 4,0" # Every Thursday(4) and Sunday(0) at 8:00 UTC diff --git a/dotnet/dependabot/README.md b/dotnet/dependabot/README.md deleted file mode 100644 index 9f1c7724588..00000000000 --- a/dotnet/dependabot/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Dependabot NuGet discovery - -This folder contains the NuGet entry point used by Dependabot. - -Dependabot's NuGet updater expands solution files before resolving package updates. The developer solution in `dotnet/agent-framework-dotnet.slnx` includes the sample projects, and expanding those projects causes the updater to exceed its fixed execution timeout before it can open dependency update pull requests. - -`agent-framework-dependabot.slnx` intentionally references only non-sample source and test projects. The referenced projects still live under `dotnet/src` and `dotnet/tests`, so MSBuild imports the central package management configuration from `dotnet/Directory.Packages.props` normally. - -Do not add sample projects to this solution. If a new non-sample project should be covered by Dependabot NuGet updates, add it here. diff --git a/dotnet/dependabot/agent-framework-dependabot.slnx b/dotnet/dependabot/agent-framework-dependabot.slnx deleted file mode 100644 index 4591ba0f068..00000000000 --- a/dotnet/dependabot/agent-framework-dependabot.slnx +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From c5a8c8d37d7cf15b493fb838084f4954be6b0223 Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 22 Sep 2026 14:13:14 +0000 Subject: [PATCH 12/42] fix(core): distinguish absent tool call arguments from parse failures (#8609) When a tool call has truly absent arguments such as None or an empty string, _extract_tool_calls now represents the arguments as an empty dictionary. Invalid JSON or non-object payloads continue to yield None to signal a parse failure, ensuring ExpectedToolCall with empty arguments does not match malformed inputs. Added unit tests for absent, malformed JSON, and non-object payloads. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg --- .../core/agent_framework/_evaluation.py | 24 +++++---- .../core/tests/core/test_local_eval.py | 54 ++++++++++++++++++- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/python/packages/core/agent_framework/_evaluation.py b/python/packages/core/agent_framework/_evaluation.py index e7ffb79088d..1c57b9f72d8 100644 --- a/python/packages/core/agent_framework/_evaluation.py +++ b/python/packages/core/agent_framework/_evaluation.py @@ -40,7 +40,7 @@ import json import logging import warnings -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from enum import Enum from typing import ( @@ -1119,15 +1119,21 @@ def _extract_tool_calls(item: EvalItem) -> list[tuple[str, dict[str, Any] | None for c in msg.contents or []: if c.type == "function_call" and c.name: args: dict[str, Any] | None = None - if isinstance(c.arguments, dict): - args = c.arguments + if c.arguments is None: + args = {} + elif isinstance(c.arguments, Mapping): + args = dict(c.arguments) elif isinstance(c.arguments, str): - try: - parsed = json.loads(c.arguments) - if isinstance(parsed, dict): - args = cast(dict[str, Any], parsed) - except (json.JSONDecodeError, TypeError): - pass + stripped = c.arguments.strip() + if not stripped: + args = {} + else: + try: + parsed = json.loads(stripped) + if isinstance(parsed, dict): + args = cast(dict[str, Any], parsed) + except (json.JSONDecodeError, TypeError): + pass calls.append((c.name, args)) return calls diff --git a/python/packages/core/tests/core/test_local_eval.py b/python/packages/core/tests/core/test_local_eval.py index e00e0d6a951..9875b2aeeee 100644 --- a/python/packages/core/tests/core/test_local_eval.py +++ b/python/packages/core/tests/core/test_local_eval.py @@ -6,6 +6,7 @@ import inspect import json +from typing import Any import pytest @@ -529,7 +530,7 @@ def check_b(response: str) -> bool: def _make_tool_call_item( - calls: list[tuple[str, dict | None]], + calls: list[tuple[str, Any]], expected: list[ExpectedToolCall] | None = None, ) -> EvalItem: """Build an EvalItem with tool calls in the conversation.""" @@ -644,6 +645,57 @@ def test_null_argument_requires_key(self, arguments, expected_passed, as_json): assert result.passed is expected_passed + def test_empty_expected_args_matches_none_actual_args(self): + item = _make_tool_call_item( + calls=[("get_weather", None)], + expected=[ExpectedToolCall("get_weather", {})], + ) + result = tool_call_args_match(item) + assert result.passed is True + + def test_empty_expected_args_matches_empty_actual_args(self): + item = _make_tool_call_item( + calls=[("get_weather", {})], + expected=[ExpectedToolCall("get_weather", {})], + ) + result = tool_call_args_match(item) + assert result.passed is True + + def test_empty_expected_args_matches_empty_string_actual_args(self): + item = _make_tool_call_item( + calls=[("get_weather", "")], + expected=[ExpectedToolCall("get_weather", {})], + ) + result = tool_call_args_match(item) + assert result.passed is True + + def test_empty_expected_args_does_not_match_invalid_json(self): + item = _make_tool_call_item( + calls=[("get_weather", "not-json")], + expected=[ExpectedToolCall("get_weather", {})], + ) + result = tool_call_args_match(item) + assert result.passed is False + assert "args mismatch" in result.reason + + def test_empty_expected_args_does_not_match_non_object_json(self): + item = _make_tool_call_item( + calls=[("get_weather", "[]")], + expected=[ExpectedToolCall("get_weather", {})], + ) + result = tool_call_args_match(item) + assert result.passed is False + assert "args mismatch" in result.reason + + def test_empty_expected_args_does_not_match_non_object_payload(self): + item = _make_tool_call_item( + calls=[("get_weather", [])], + expected=[ExpectedToolCall("get_weather", {})], + ) + result = tool_call_args_match(item) + assert result.passed is False + assert "args mismatch" in result.reason + def test_tool_not_called(self): item = _make_tool_call_item( calls=[("get_news", None)], From bd379d174bf2dc3bdd4be1fea3a0679c522078fd Mon Sep 17 00:00:00 2001 From: Sunhy <58774225+Shy7777@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:21:50 +0000 Subject: [PATCH 13/42] Python: preserve Unicode file store display paths (#8630) --- .../agent_framework/_harness/_file_access.py | 22 +++++++------ .../tests/core/test_harness_file_access.py | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index c7c48dae495..6191bb98f7d 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -1113,6 +1113,7 @@ async def list_children(self, directory: str = "") -> list[FileStoreEntry]: prefix = _normalize_relative_path(directory, is_directory=True).lower() if prefix and not prefix.endswith("/"): prefix += "/" + prefix_depth = prefix.count("/") async with self._lock: entries = [(key, display) for key, (display, _) in self._files.items()] files: list[str] = [] @@ -1121,18 +1122,18 @@ async def list_children(self, directory: str = "") -> list[FileStoreEntry]: for key, display in entries: if not key.startswith(prefix): continue - remainder = key[len(prefix) :] - separator_index = remainder.find("/") - if separator_index == -1: - # ``display`` is the original-case normalized path; strip the - # directory prefix using the same length we matched on ``key``. - files.append(display[len(prefix) :]) - elif separator_index > 0: - segment_key = remainder[:separator_index] + # Unicode lowercasing can change character counts, so key offsets + # cannot be used to slice the original display path. + remainder = display.split("/", prefix_depth)[-1] + segment, separator, _ = remainder.partition("/") + if not separator: + files.append(remainder) + elif segment: + segment_key = segment.lower() if segment_key in seen_dirs: continue seen_dirs.add(segment_key) - directories.append(display[len(prefix) : len(prefix) + separator_index]) + directories.append(segment) results: list[FileStoreEntry] = [FileStoreEntry(name, FileStoreEntry.DIRECTORY) for name in directories] results.extend(FileStoreEntry(name, FileStoreEntry.FILE) for name in files) return results @@ -1165,6 +1166,7 @@ async def search( prefix = _normalize_relative_path(directory, is_directory=True).lower() if prefix and not prefix.endswith("/"): prefix += "/" + prefix_depth = prefix.count("/") search_pattern = _compile_search_regex(regex_pattern) async with self._lock: @@ -1178,7 +1180,7 @@ def scan() -> list[FileSearchResult]: relative_key = key[len(prefix) :] if not recursive and "/" in relative_key: continue - relative_display = display[len(prefix) :] + relative_display = display.split("/", prefix_depth)[-1] if not _matches_glob(relative_display, glob_pattern): continue result = AgentFileStore.scan_content(relative_display, file_content, search_pattern) diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index 9cbffc96abf..8c01b1c6146 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -175,6 +175,38 @@ async def test_in_memory_store_round_trips_files() -> None: assert sorted(await _list_files(store)) == [] +@pytest.mark.parametrize("directory", ["İstanbul", "i\u0307stanbul", "İSTANBUL/"]) +async def test_in_memory_store_lists_unicode_paths_without_truncation(directory: str) -> None: + """Length-changing lowercasing must not alter the displayed path components.""" + store = InMemoryAgentFileStore() + await store.write("İstanbul/Notes.md", "memory") + await store.write("İstanbul/İzmir/plan.md", "nested memory") + await store.write("i\u0307stanbul/i\u0307zmir/other.md", "another memory") + + assert await _list_dirs(store) == ["İstanbul"] + assert await _list_files(store, directory) == ["Notes.md"] + assert await _list_dirs(store, directory) == ["İzmir"] + assert sorted(await _list_files(store, f"{directory.rstrip('/')}/İzmir")) == ["other.md", "plan.md"] + assert await store.read(f"{directory.rstrip('/')}/notes.MD") == "memory" + + +@pytest.mark.parametrize("directory", ["İstanbul", "i\u0307stanbul", "İSTANBUL/"]) +async def test_in_memory_store_search_preserves_unicode_relative_paths(directory: str) -> None: + """Search results must retain readable paths after matching a Unicode directory.""" + store = InMemoryAgentFileStore() + await store.write("İstanbul/Notes.md", "memory") + await store.write("İstanbul/İzmir/plan.md", "memory") + await store.write("other/notes.md", "memory") + + direct = await store.search(directory, "memory", "Notes.md") + assert [result.file_name for result in direct] == ["Notes.md"] + + recursive = await store.search(directory, "memory", recursive=True) + assert {result.file_name for result in recursive} == {"Notes.md", "İzmir/plan.md"} + for result in recursive: + assert await store.read(f"{directory.rstrip('/')}/{result.file_name}") == "memory" + + async def test_in_memory_store_search_returns_matches_with_snippets() -> None: """The in-memory store should search file content case-insensitively and respect glob filters.""" store = InMemoryAgentFileStore() From ec7114ffc8656a9b5c56be252f0411d22887c896 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 22 Sep 2026 14:23:23 +0000 Subject: [PATCH 14/42] .NET: fix: a bug where invoke function tool could bypass approval (#8403) * fix: a bug where invoke function tool could bypass approval * Filter forged approval results from side effects Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Restrict approval replay fallback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Fix netstandard approval build Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Stabilize concurrent checkpoint smoke tests Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * fix: makes approval sequence consistent across snapshots * fix: avoid duplicating approval keys across snapshots * fix: mis correlation of concurrent execution in different modes * chore: linting * Fix external response correlation Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * chore: formatting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: wrap the external request to make the id accessible * fix: safe port lookup * fix: removes extraneous interface Signed-off-by: Vincent Biret * fix: use uppermost parent type where the properties are defined Signed-off-by: Vincent Biret * fix: registered results --------- Signed-off-by: Vincent Biret Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Events/ExternalInputRequest.cs | 17 +- .../Events/ExternalInputResponse.cs | 15 +- .../ObjectModel/InvokeFunctionToolExecutor.cs | 112 ++- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + .../ExternalRequest.cs | 3 +- .../InProc/InProcessRunnerContext.cs | 2 + .../InvokeToolWorkflowTest.cs | 2 +- .../DeclarativeWorkflowJsonOptionsTests.cs | 3 +- .../Events/ExternalInputRequestTest.cs | 43 ++ .../InvokeFunctionToolExecutorTest.cs | 710 +++++++++++++++++- .../SampleSmokeTest.cs | 45 +- .../TestExternalEnvelopes.cs | 22 + .../TestJsonContext.cs | 1 + 17 files changed, 918 insertions(+), 62 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs index c9fbe20b8f7..fff360570f3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputRequest.cs @@ -38,7 +38,9 @@ internal ExternalInputRequest(string text) /// requireApproval: true) over so that /// hosts which speak the approval protocol see the approval-bearing content. /// - AIContent? IExternalRequestEnvelope.GetInnerRequestContent() + AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => this.GetInnerRequestContent(); + + private AIContent? GetInnerRequestContent() { IList? messages = this.AgentResponse?.Messages; if (messages is null) @@ -73,5 +75,16 @@ internal ExternalInputRequest(string text) /// object IExternalRequestEnvelope.CreateResponse(IList messages) - => new ExternalInputResponse(messages); + => new ExternalInputResponse(messages) + { + RequestId = GetContentId(this.GetInnerRequestContent()), + }; + + private static string? GetContentId(AIContent? content) + => content switch + { + ToolCallContent functionCall => functionCall.CallId, + InputRequestContent toolApprovalRequest => toolApprovalRequest.RequestId, + _ => null, + }; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs index 0653a12ce5b..fe13433b3ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/ExternalInputResponse.cs @@ -16,6 +16,12 @@ public sealed class ExternalInputResponse /// public IList Messages { get; } + /// + /// Gets the request id that produced this response, if available. + /// + [JsonInclude] + public string? RequestId { get; internal init; } + internal bool HasMessages => this.Messages?.Count > 0; /// @@ -23,8 +29,8 @@ public sealed class ExternalInputResponse /// /// The external input message being provided to the workflow. public ExternalInputResponse(ChatMessage message) + : this([message], requestId: null) { - this.Messages = [message]; } /// @@ -33,7 +39,14 @@ public ExternalInputResponse(ChatMessage message) /// The external input messages being provided to the workflow. [JsonConstructor] public ExternalInputResponse(IList messages) + : this(messages, requestId: null) + { + } + + internal ExternalInputResponse(IList messages, string? requestId) { this.Messages = messages; + this.RequestId = requestId; } + } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 386c3712b95..20699ee4aa3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -29,6 +29,7 @@ internal sealed class InvokeFunctionToolExecutor( DeclarativeActionExecutor(model, state) { private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshots); + private const string HasApprovalRequiredInvocationStateKey = nameof(_hasApprovalRequiredInvocation); private const string PendingCallIdsStateKey = nameof(_pendingNonApprovalCallIds); private const string LegacyApprovalSnapshotStateKey = "_approvalSnapshot"; @@ -39,6 +40,8 @@ internal sealed class InvokeFunctionToolExecutor( /// private readonly ConcurrentDictionary _approvalSnapshots = new(StringComparer.Ordinal); + private bool _hasApprovalRequiredInvocation; + /// /// Per-invocation call ids for in-flight non-approval requests; used to match the /// returning on the response path. Used as a set; @@ -94,6 +97,7 @@ public static class Steps // Capture the evaluated parameters keyed by request id; the matching response // resumes from this snapshot. this._approvalSnapshots[requestId] = new ApprovalSnapshot(functionName, arguments); + this._hasApprovalRequiredInvocation = true; requestMessage.Contents.Add(new ToolApprovalRequestContent(requestId, functionCall)); } @@ -125,24 +129,29 @@ public async ValueTask CaptureResponseAsync( { bool autoSend = this.GetAutoSendValue(); string? conversationId = this.GetConversationId(); + string? responseRequestId = response.RequestId; // Match the inbound result by its per-invocation call id. FunctionResultContent? matchingResult = response.Messages .SelectMany(m => m.Contents) .OfType() - .FirstOrDefault(r => this.IsKnownPendingId(r.CallId)); + .FirstOrDefault(r => this.IsCorrelatedPendingNonApprovalResult(r, responseRequestId)); + FunctionResultContent? approvedFunctionResult = null; - // Legacy non-approval backstop: when no pendings are tracked, accept a result - // whose CallId equals this.Id. The runtime has already routed the response to - // this executor's port and the framework does not invoke a function here. + // Legacy non-approval backstop: when no pendings are tracked and approval is + // not required, accept a result whose CallId equals this.Id. The runtime has + // already routed the response to this executor's port and the framework does + // not invoke a function here. if (matchingResult is null + && !this._hasApprovalRequiredInvocation && this._pendingNonApprovalCallIds.IsEmpty && this._approvalSnapshots.IsEmpty) { matchingResult = response.Messages .SelectMany(m => m.Contents) .OfType() - .FirstOrDefault(r => string.Equals(r.CallId, this.Id, StringComparison.Ordinal)); + .FirstOrDefault(r => string.Equals(r.CallId, this.Id, StringComparison.Ordinal) + && IsCorrelatedResponse(r.CallId, responseRequestId)); } // When the caller approved an approval-required function call but didn't execute it @@ -160,8 +169,9 @@ public async ValueTask CaptureResponseAsync( // Prefer an approval matching a pending snapshot; otherwise take the first // present approval. ToolApprovalResponseContent? approval = - approvals.FirstOrDefault(r => this._approvalSnapshots.ContainsKey(r.RequestId)) - ?? approvals.FirstOrDefault(); + approvals.FirstOrDefault(r => this._approvalSnapshots.ContainsKey(r.RequestId) + && IsCorrelatedResponse(r.RequestId, responseRequestId)) + ?? approvals.FirstOrDefault(r => IsCorrelatedResponse(r.RequestId, responseRequestId)); if (approval is not null) { @@ -177,12 +187,17 @@ public async ValueTask CaptureResponseAsync( else if (this._approvalSnapshots.TryRemove(approval.RequestId, out ApprovalSnapshot? snapshot)) { matchingResult = await this.InvokeRegisteredFunctionAsync(approval.RequestId, snapshot, cancellationToken).ConfigureAwait(false); + approvedFunctionResult = matchingResult; } else { await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false); } } + else if (!this._approvalSnapshots.IsEmpty) + { + await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false); + } } if (matchingResult is not null) @@ -196,16 +211,15 @@ public async ValueTask CaptureResponseAsync( AgentResponse resultResponse = new([new ChatMessage(ChatRole.Tool, [matchingResult])]); await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false); } - - // Drop the per-invocation entry now that the response has been processed. - this._pendingNonApprovalCallIds.TryRemove(matchingResult.CallId, out _); - this._approvalSnapshots.TryRemove(matchingResult.CallId, out _); } // Store messages if output path is configured if (this.Model.Output?.Messages is not null) { - await this.AssignAsync(this.Model.Output.Messages?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync( + this.Model.Output.Messages?.Path, + this.GetSideEffectMessages(response).ToFormula(), + context).ConfigureAwait(false); } // Add messages to conversation if conversationId is provided @@ -214,37 +228,60 @@ public async ValueTask CaptureResponseAsync( // actual AI-generated tool calls and would be rejected by the API. if (conversationId is not null) { - foreach (ChatMessage message in TransformConversationMessages(response.Messages)) + IEnumerable sideEffectMessages = this.GetSideEffectMessages(response); + if (approvedFunctionResult is not null) + { + sideEffectMessages = sideEffectMessages.Append(new ChatMessage(ChatRole.Tool, [approvedFunctionResult])); + } + + foreach (ChatMessage message in TransformConversationMessages(sideEffectMessages)) { await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); } } + if (matchingResult is not null) + { + // Drop the per-invocation entry now that the response side effects have been processed. + this._pendingNonApprovalCallIds.TryRemove(matchingResult.CallId, out _); + this._approvalSnapshots.TryRemove(matchingResult.CallId, out _); + } + // Completes the action after processing the function result. await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } - private bool IsKnownPendingId(string callId) => - this._pendingNonApprovalCallIds.ContainsKey(callId) || this._approvalSnapshots.ContainsKey(callId); + private IEnumerable GetSideEffectMessages(ExternalInputResponse response) + { + if (!this._hasApprovalRequiredInvocation) + { + return response.Messages; + } + + return this.FilterRejectedApprovalResults(response); + } /// public override ValueTask ResetAsync() { this._approvalSnapshots.Clear(); + this._hasApprovalRequiredInvocation = false; this._pendingNonApprovalCallIds.Clear(); return default; } /// /// - /// Persists pending approval snapshots and non-approval call ids so they survive - /// checkpoint/restore cycles. + /// Persists approval classification, pending approval snapshots, and non-approval + /// call ids so they survive checkpoint/restore cycles. /// protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { Dictionary snapshotCopy = this._approvalSnapshots.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal); await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, snapshotCopy, null, cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasApprovalRequiredInvocationStateKey, this._hasApprovalRequiredInvocation, null, cancellationToken).ConfigureAwait(false); + List pendingCopy = [.. this._pendingNonApprovalCallIds.Keys]; await context.QueueStateUpdateAsync(PendingCallIdsStateKey, pendingCopy, null, cancellationToken).ConfigureAwait(false); @@ -259,7 +296,6 @@ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); - this._approvalSnapshots.Clear(); Dictionary? snapshots = await context.ReadStateAsync>( ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false); @@ -271,6 +307,10 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co } } + this._hasApprovalRequiredInvocation = + await context.ReadStateAsync(HasApprovalRequiredInvocationStateKey, null, cancellationToken).ConfigureAwait(false) + || !this._approvalSnapshots.IsEmpty; + this._pendingNonApprovalCallIds.Clear(); List? pending = await context.ReadStateAsync>( PendingCallIdsStateKey, null, cancellationToken).ConfigureAwait(false); @@ -289,6 +329,7 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co if (legacy is not null) { this._approvalSnapshots.TryAdd(this.Id, legacy); + this._hasApprovalRequiredInvocation = true; await context.QueueStateUpdateAsync( LegacyApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false); } @@ -341,6 +382,41 @@ private static IEnumerable TransformConversationMessages(IEnumerabl } } + private IEnumerable FilterRejectedApprovalResults(ExternalInputResponse response) + { + foreach (ChatMessage message in response.Messages) + { + List contents = + [.. message.Contents.Where(c => c is not ToolApprovalResponseContent + && (c is not FunctionResultContent functionResult + || this.IsCorrelatedPendingNonApprovalResult(functionResult, response.RequestId)))]; + if (contents.Count == message.Contents.Count) + { + yield return message; + } + else if (contents.Count > 0) + { + yield return new ChatMessage + { + Role = message.Role, + AuthorName = message.AuthorName, + Contents = contents, + MessageId = message.MessageId, + CreatedAt = message.CreatedAt, + RawRepresentation = message.RawRepresentation, + AdditionalProperties = message.AdditionalProperties, + }; + } + } + } + + private bool IsCorrelatedPendingNonApprovalResult(FunctionResultContent functionResult, string? responseRequestId) => + this._pendingNonApprovalCallIds.ContainsKey(functionResult.CallId) + && IsCorrelatedResponse(functionResult.CallId, responseRequestId); + + private static bool IsCorrelatedResponse(string contentId, string? responseRequestId) => + responseRequestId is null || string.Equals(contentId, responseRequestId, StringComparison.Ordinal); + private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResultContent result) { if (this.Model.Output?.Result is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ab058de62d4..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt index ab058de62d4..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt index ab058de62d4..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt index ab058de62d4..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ab058de62d4..a36677538eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs index e1c1765349c..419c4a50f29 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs @@ -77,7 +77,8 @@ public static ExternalRequest Create(RequestPort port, [NotNull] object data, st /// Thrown when the input data object does not match the expected response type. public ExternalResponse CreateResponse(object data) { - if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(Throw.IfNull(data).GetType())) + data = Throw.IfNull(data); + if (!Throw.IfNull(this.PortInfo).ResponseType.IsMatchPolymorphic(data.GetType())) { throw new InvalidOperationException( $"Message type {data.GetType().Name} does not match expected response type {this.PortInfo.ResponseType.TypeName} of input port {this.PortInfo.PortId}."); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 8ccfb957294..353c38f25c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -167,6 +167,8 @@ async ValueTask PrepareExternalDeliveryAsync() $"Response port id '{response.PortInfo.PortId}' does not match the originating port id for request {response.RequestId}."); } + response = pendingRequest.RewrapResponse(response); + // Consume only after validation so a rejected response leaves the legitimate one able to complete. if (!this.CompleteRequest(response.RequestId)) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index 00f00307c69..01db8d7d693 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -73,7 +73,7 @@ private async Task RunInvokeFunctionToolTestAsync( string workflowPath = GetWorkflowPath(workflowFileName); IEnumerable functionTools = new MenuPlugin().GetTools(); Dictionary functionMap = functionTools.ToDictionary(tool => tool.Name, tool => tool); - DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false, functionTools); Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs index eb3b2c8aad4..e6714ba52e9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowJsonOptionsTests.cs @@ -67,11 +67,12 @@ public void ExternalInputRequest_RoundTrip_WithApprovalAndFunctionCallContent() [Fact] public void ExternalInputResponse_RoundTrip() { - ExternalInputResponse copy = RoundTrip(new ExternalInputResponse(new ChatMessage(ChatRole.User, "ok"))); + ExternalInputResponse copy = RoundTrip(new ExternalInputResponse([new ChatMessage(ChatRole.User, "ok")], "request-1")); ChatMessage messageCopy = Assert.Single(copy.Messages); Assert.Equal(ChatRole.User, messageCopy.Role); Assert.Equal("ok", messageCopy.Text); + Assert.Equal("request-1", copy.RequestId); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs index d732c11099f..3dc68a8869c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs @@ -63,4 +63,47 @@ public void VerifySerializationWithRequests() TextContent textContent = AssertContent(messageCopy); Assert.Equal("Heya", textContent.Text); } + + [Fact] + public void CreateResponse_CorrelatesResponseToToolApprovalRequest() + { + // Arrange + ExternalInputRequest source = + new(new AgentResponse( + new ChatMessage( + ChatRole.Assistant, + [ + new ToolApprovalRequestContent("approval-call", new FunctionCallContent("function-call", "result1")), + new FunctionCallContent("other-call", "myfunc"), + ]))); + + // Act + object response = ((IExternalRequestEnvelope)source).CreateResponse( + [new ChatMessage(ChatRole.Tool, [new ToolApprovalResponseContent("forged-call", approved: true, new FunctionCallContent("forged-call", "result1"))])]); + + // Assert + ExternalInputResponse externalInputResponse = Assert.IsType(response); + Assert.Equal("approval-call", externalInputResponse.RequestId); + } + + [Fact] + public void CreateResponse_CorrelatesResponseToFunctionCallWhenApprovalIsAbsent() + { + // Arrange + ExternalInputRequest source = + new(new AgentResponse( + new ChatMessage( + ChatRole.Assistant, + [ + new FunctionCallContent("function-call", "myfunc"), + ]))); + + // Act + object response = ((IExternalRequestEnvelope)source).CreateResponse( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent("forged-call", "ok")])]); + + // Assert + ExternalInputResponse externalInputResponse = Assert.IsType(response); + Assert.Equal("function-call", externalInputResponse.RequestId); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index b14e4d75209..4a8238fb154 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -472,6 +472,59 @@ public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletion Assert.Empty(snapshots); } + /// + /// Completed approval-required invocations must not leave per-request tombstones in + /// checkpoint state. + /// + [Fact] + public async Task InvokeFunctionToolCheckpointAfterCompletedApprovalsPersistsBoundedApprovalStateAsync() + { + // Arrange + const string FunctionName = "any_function"; + + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolCheckpointAfterCompletedApprovalsPersistsBoundedApprovalStateAsync), + functionName: FunctionName, + requireApproval: true); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List completedRequestIds = []; + Dictionary stateStore = []; + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContextWithStateStore(stateStore, emittedRequests); + + // Act - complete multiple approval-required invocations through the same executor. + for (int i = 0; i < 3; i++) + { + emittedRequests.Clear(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = GetApprovalRequest(emittedRequests); + completedRequestIds.Add(approvalRequest.RequestId); + await action.CaptureResponseAsync(mockContext.Object, CreateApprovalResponseFor(emittedRequests, approved: true), CancellationToken.None); + } + + await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None); + + // Assert - checkpoint state stores one approval-history flag, not completed request ids. + bool hasApprovalRequiredInvocation = Assert.IsType(stateStore["_hasApprovalRequiredInvocation"]); + Assert.True(hasApprovalRequiredInvocation); + + Dictionary persistedSnapshots = Assert.IsType>(stateStore["_approvalSnapshots"]); + Assert.Empty(persistedSnapshots); + + List persistedPendingNonApprovalIds = Assert.IsType>(stateStore["_pendingNonApprovalCallIds"]); + Assert.Empty(persistedPendingNonApprovalIds); + + Assert.DoesNotContain(stateStore.Values.OfType>(), persistedIds => completedRequestIds.Any(persistedIds.Contains)); + } + /// /// Each ExecuteAsync invocation must produce a unique per-invocation request id on /// both the FunctionCallContent.CallId and the ToolApprovalRequestContent.RequestId. @@ -643,6 +696,119 @@ public async Task InvokeFunctionToolNonApprovalCallIdsAreDistinctPerInvocationAs Assert.NotEqual(action.Id, fcc2.CallId); } + /// + /// A non-approval function result must not satisfy a concurrent approval-required + /// invocation just because its call id is pending on the same executor. + /// + [Fact] + public async Task InvokeFunctionToolApprovalResponseRejectsConcurrentNonApprovalResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string RequireApprovalVariable = "NeedsApproval"; + const string ResultVariable = "Result"; + const string CrossModeResult = "cross-mode-result"; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(false)); + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModelWithVariableRequireApproval( + displayName: nameof(InvokeFunctionToolApprovalResponseRejectsConcurrentNonApprovalResultAsync), + functionName: FunctionName, + requireApprovalVariableName: RequireApprovalVariable, + outputResultVariable: ResultVariable); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "registered-result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + string nonApprovalCallId = GetFunctionCall(emittedRequests[0]).CallId; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(true)); + this.State.Bind(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + string approvalRequestId = GetApprovalRequest(emittedRequests[1]).RequestId; + + ExternalInputResponse crossModeResponse = CreateFunctionResultResponse( + request: emittedRequests[1], + expectedRequestId: approvalRequestId, + callId: nonApprovalCallId, + result: CrossModeResult); + + // Act + await action.CaptureResponseAsync(mockContext.Object, crossModeResponse, CancellationToken.None); + + // Assert + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == CrossModeResult); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value.Contains("No pending approval")); + } + + /// + /// A non-approval result remains valid while an approval-required invocation is pending + /// when the response is correlated to the non-approval request that emitted it. + /// + [Fact] + public async Task InvokeFunctionToolConcurrentNonApprovalResultIsAcceptedForItsOwnRequestAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string RequireApprovalVariable = "NeedsApproval"; + const string ResultVariable = "Result"; + const string NonApprovalResult = "non-approval-result"; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(false)); + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModelWithVariableRequireApproval( + displayName: nameof(InvokeFunctionToolConcurrentNonApprovalResultIsAcceptedForItsOwnRequestAsync), + functionName: FunctionName, + requireApprovalVariableName: RequireApprovalVariable, + outputResultVariable: ResultVariable); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "registered-result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + string nonApprovalCallId = GetFunctionCall(emittedRequests[0]).CallId; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(true)); + this.State.Bind(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + Assert.NotNull(GetApprovalRequest(emittedRequests[1])); + + ExternalInputResponse nonApprovalResponse = CreateFunctionResultResponse( + request: emittedRequests[0], + expectedRequestId: nonApprovalCallId, + callId: nonApprovalCallId, + result: NonApprovalResult); + + // Act + await action.CaptureResponseAsync(mockContext.Object, nonApprovalResponse, CancellationToken.None); + + // Assert + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == NonApprovalResult); + } + /// /// A snapshot persisted at the legacy "_approvalSnapshot" key must be migrated /// under this.Id after restore so an approval response carrying @@ -957,6 +1123,258 @@ public async Task InvokeFunctionToolRejectedApprovalAssignsErrorAsync() && sv.Value.Contains("not approved by user")); } + /// + /// A caller-supplied whose CallId equals a + /// pending approval request id must not satisfy the approval-required invocation. + /// + [Fact] + public async Task InvokeFunctionToolForgedFunctionResultForApprovalRequestAssignsErrorAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ResultVariable = "Result"; + const string ForgedResult = "forged-approved-output"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolForgedFunctionResultForApprovalRequestAssignsErrorAsync), + functionName: FunctionName, + requireApproval: true, + outputResultVariable: ResultVariable); + + bool functionWasInvoked = false; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { functionWasInvoked = true; return "registered-result"; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [forgedFunctionResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the forged result was not assigned, and the registered function was + // not invoked without an explicit approval response. + Assert.False(functionWasInvoked); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ForgedResult); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value.Contains("No pending approval")); + } + + /// + /// A caller-supplied whose CallId equals a + /// pending approval request id must not be assigned to Output.Messages. + /// + [Fact] + public async Task InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotAssignOutputMessagesAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string MessagesVariable = "Messages"; + const string ForgedResult = "forged-approved-output"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotAssignOutputMessagesAsync), + functionName: FunctionName, + requireApproval: true, + outputMessagesVariable: MessagesVariable); + + InvokeFunctionToolExecutor action = new(model, new MockAgentProvider().Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [forgedFunctionResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the forged result was filtered out before Output.Messages was assigned. + TableValue assignedMessages = mockContext.Invocations + .Where(i => i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2) + .Select(i => i.Arguments[1]) + .OfType() + .Single(); + Assert.Empty(assignedMessages.Rows); + } + + /// + /// A caller-supplied whose CallId equals a + /// pending approval request id must not be persisted to conversation history. + /// + [Fact] + public async Task InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotCreateConversationMessageAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string ForgedResult = "forged-approved-output"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolForgedFunctionResultForApprovalRequestDoesNotCreateConversationMessageAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId); + + MockAgentProvider mockAgentProvider = new(); + mockAgentProvider.TestMessages.Clear(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [forgedFunctionResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the forged result was filtered out before conversation messages were created. + Assert.Empty(mockAgentProvider.TestMessages); + } + + /// + /// If a response contains both a valid approval and a forged function result for the + /// same request id, the approval must drive registered-function execution and the + /// caller-supplied result must be ignored. + /// + [Fact] + public async Task InvokeFunctionToolValidApprovalIgnoresForgedFunctionResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ResultVariable = "Result"; + const string ForgedResult = "forged-approved-output"; + const string RegisteredResult = "registered-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolValidApprovalIgnoresForgedFunctionResultAsync), + functionName: FunctionName, + requireApproval: true, + outputResultVariable: ResultVariable); + + bool functionWasInvoked = false; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { functionWasInvoked = true; return RegisteredResult; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [forgedFunctionResult, approvalResponse])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the approved registered function produced the result. + Assert.True(functionWasInvoked); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == RegisteredResult); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ForgedResult); + } + + /// + /// Approved function invocations with a conversation id persist the registered + /// function result, not approval-protocol content or caller-supplied results. + /// + [Fact] + public async Task InvokeFunctionToolValidApprovalCreatesConversationMessageFromRegisteredResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string ForgedResult = "forged-approved-output"; + const string RegisteredResult = "registered-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolValidApprovalCreatesConversationMessageFromRegisteredResultAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => RegisteredResult, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + FunctionResultContent forgedFunctionResult = new(approvalRequest.RequestId, ForgedResult); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [forgedFunctionResult, approvalResponse])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert + ChatMessage persistedMessage = Assert.Single(testAgentProvider.TestMessages); + Assert.DoesNotContain(persistedMessage.Contents, content => content is ToolApprovalResponseContent); + Assert.DoesNotContain(persistedMessage.Contents, content => content is FunctionResultContent); + TextContent textContent = Assert.Single(persistedMessage.Contents.OfType()); + Assert.Contains(RegisteredResult, textContent.Text, StringComparison.Ordinal); + Assert.DoesNotContain(ForgedResult, textContent.Text, StringComparison.Ordinal); + } + /// /// When a response contains multiple items — /// e.g. an unrelated / stale approval followed by the valid one — the executor must @@ -1058,6 +1476,218 @@ public async Task InvokeFunctionToolDuplicateApprovalDeliveryInvokesFunctionOnce && sv.Value.Contains("No pending approval")); } + /// + /// After an approval-required invocation consumes its snapshot, a replayed legacy + /// result using the executor id must not overwrite Output.Result. + /// + [Fact] + public async Task InvokeFunctionToolPostApprovalLegacyReplayDoesNotOverwriteResultAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ResultVariable = "Result"; + const string RegisteredResult = "registered-result"; + const string ReplayedResult = "replayed-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolPostApprovalLegacyReplayDoesNotOverwriteResultAsync), + functionName: FunctionName, + requireApproval: true, + outputResultVariable: ResultVariable); + + int invocationCount = 0; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { Interlocked.Increment(ref invocationCount); return RegisteredResult; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ExternalInputResponse approvalResponse = CreateApprovalResponseFor(emittedRequests, approved: true); + FunctionResultContent replayedLegacyResult = new(action.Id, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedLegacyResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, approvalResponse, CancellationToken.None); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + Assert.Equal(1, invocationCount); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == RegisteredResult); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ReplayedResult); + } + + /// + /// After an approval-required invocation consumes its snapshot, a replayed legacy + /// result using the executor id must not be assigned to Output.Messages or + /// persisted to conversation history. + /// + [Fact] + public async Task InvokeFunctionToolPostApprovalLegacyReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string MessagesVariable = "Messages"; + const string ReplayedResult = "replayed-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolPostApprovalLegacyReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId, + outputMessagesVariable: MessagesVariable); + + MockAgentProvider mockAgentProvider = new(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ExternalInputResponse approvalResponse = CreateApprovalResponseFor(emittedRequests, approved: true); + FunctionResultContent replayedLegacyResult = new(action.Id, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedLegacyResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, approvalResponse, CancellationToken.None); + mockContext.Invocations.Clear(); + mockAgentProvider.TestMessages.Clear(); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + TableValue assignedMessages = mockContext.Invocations + .Where(i => i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2) + .Select(i => i.Arguments[1]) + .OfType() + .Single(); + Assert.Empty(assignedMessages.Rows); + Assert.Empty(mockAgentProvider.TestMessages); + } + + /// + /// After an approval-required invocation is rejected and consumes its snapshot, a replayed + /// result using the approval request id must not be assigned to Output.Messages or + /// persisted to conversation history. + /// + [Fact] + public async Task InvokeFunctionToolPostRejectionReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string ConversationId = "TestConversationId"; + const string MessagesVariable = "Messages"; + const string ReplayedResult = "replayed-result"; + + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolPostRejectionReplayDoesNotAssignOutputMessagesOrCreateConversationMessageAsync), + functionName: FunctionName, + requireApproval: true, + conversationId: ConversationId, + outputMessagesVariable: MessagesVariable); + + MockAgentProvider mockAgentProvider = new(); + InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + ToolApprovalRequestContent approvalRequest = GetApprovalRequest(emittedRequests); + ExternalInputResponse rejectionResponse = CreateApprovalResponseFor(emittedRequests, approved: false); + FunctionResultContent replayedApprovalResult = new(approvalRequest.RequestId, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedApprovalResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, rejectionResponse, CancellationToken.None); + mockContext.Invocations.Clear(); + mockAgentProvider.TestMessages.Clear(); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + TableValue assignedMessages = mockContext.Invocations + .Where(i => i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2) + .Select(i => i.Arguments[1]) + .OfType() + .Single(); + Assert.Empty(assignedMessages.Rows); + Assert.Empty(mockAgentProvider.TestMessages); + } + + /// + /// The approval mode captured at emit time must gate legacy replay matching even if + /// the mutable RequireApproval expression evaluates to false after approval. + /// + [Fact] + public async Task InvokeFunctionToolPostApprovalLegacyReplayUsesEmittedApprovalModeAsync() + { + // Arrange + const string FunctionName = "any_function"; + const string RequireApprovalVariable = "NeedsApproval"; + const string ResultVariable = "Result"; + const string RegisteredResult = "registered-result"; + const string ReplayedResult = "replayed-result"; + + this.State.Set(RequireApprovalVariable, FormulaValue.New(true)); + this.State.InitializeSystem(); + this.State.Bind(); + InvokeFunctionTool model = this.CreateModelWithVariableRequireApproval( + displayName: nameof(InvokeFunctionToolPostApprovalLegacyReplayUsesEmittedApprovalModeAsync), + functionName: FunctionName, + requireApprovalVariableName: RequireApprovalVariable, + outputResultVariable: ResultVariable); + + int invocationCount = 0; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => { Interlocked.Increment(ref invocationCount); return RegisteredResult; }, name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + this.State.Set(RequireApprovalVariable, FormulaValue.New(false)); + this.State.Bind(); + + ExternalInputResponse approvalResponse = CreateApprovalResponseFor(emittedRequests, approved: true); + FunctionResultContent replayedLegacyResult = new(action.Id, ReplayedResult); + ExternalInputResponse replayResponse = new(new ChatMessage(ChatRole.Tool, [replayedLegacyResult])); + + // Act + await action.CaptureResponseAsync(mockContext.Object, approvalResponse, CancellationToken.None); + await action.CaptureResponseAsync(mockContext.Object, replayResponse, CancellationToken.None); + + // Assert + Assert.Equal(1, invocationCount); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == RegisteredResult); + Assert.DoesNotContain(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == ReplayedResult); + } + /// /// A non-approval FunctionResultContent whose CallId equals this.Id is /// consumed and assigned to Output.Result when no pendings are tracked. @@ -1170,6 +1800,45 @@ private static ExternalInputResponse CreateApprovalResponseFor(IReadOnlyList emittedRequests) + { + return Assert.Single(emittedRequests) + .AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + } + + private static ToolApprovalRequestContent GetApprovalRequest(ExternalInputRequest emittedRequest) + { + return emittedRequest.AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + } + + private static FunctionCallContent GetFunctionCall(ExternalInputRequest emittedRequest) + { + return emittedRequest.AgentResponse.Messages + .SelectMany(m => m.Contents) + .OfType() + .Single(); + } + + private static ExternalInputResponse CreateFunctionResultResponse( + ExternalInputRequest request, + string expectedRequestId, + string callId, + string result) + { + object response = ((IExternalRequestEnvelope)request).CreateResponse( + [new ChatMessage(ChatRole.Tool, [new FunctionResultContent(callId, result)])]); + + ExternalInputResponse correlatedResponse = Assert.IsType(response); + Assert.Equal(expectedRequestId, correlatedResponse.RequestId); + return correlatedResponse; + } + private static Mock CreateMockWorkflowContext(List? emittedRequests = null) { Mock mockContext = new(); @@ -1208,6 +1877,9 @@ private static Mock CreateMockWorkflowContextWithStateStore( mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) .Callback, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value) .Returns(default(ValueTask)); + mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((key, value, _, _) => stateStore[key] = value) + .Returns(default(ValueTask)); mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((key, value, _, _) => { @@ -1236,6 +1908,9 @@ private static Mock CreateMockWorkflowContextWithStateStore( mockContext.Setup(c => c.ReadStateAsync>(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((key, _, _) => new ValueTask?>(stateStore.TryGetValue(key, out object? val) ? val as List : null)); + mockContext.Setup(c => c.ReadStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((key, _, _) => + new ValueTask(stateStore.TryGetValue(key, out object? val) && val is bool boolValue && boolValue)); mockContext.Setup(c => c.ReadStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((key, _, _) => new ValueTask(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null)); @@ -1265,6 +1940,8 @@ private sealed class TestFunctionAgentProvider : ResponseAgentProvider private readonly Action? _onInvoke; private readonly Action? _onInvokeArguments; + public List TestMessages { get; } = []; + public TestFunctionAgentProvider( IEnumerable functions, Action? onInvoke = null, @@ -1287,8 +1964,11 @@ internal void RecordInvocation(string name, AIFunctionArguments? arguments) public override Task CreateConversationAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) + { + this.TestMessages.Add(conversationMessage); + return Task.FromResult(conversationMessage); + } public override Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1355,7 +2035,8 @@ private InvokeFunctionTool CreateModel( string? conversationId = null, string? argumentKey = null, string? argumentValue = null, - string? outputResultVariable = null) + string? outputResultVariable = null, + string? outputMessagesVariable = null) { InvokeFunctionTool.Builder builder = new() { @@ -1375,11 +2056,12 @@ private InvokeFunctionTool CreateModel( builder.Arguments.Add(argumentKey, ValueExpression.Literal(new StringDataValue(argumentValue))); } - if (outputResultVariable is not null) + if (outputResultVariable is not null || outputMessagesVariable is not null) { builder.Output = new InvokeToolOutput.Builder { - Result = new InitializablePropertyPath(PropertyPath.TopicVariable(outputResultVariable), isInitializer: false), + Result = outputResultVariable is not null ? new InitializablePropertyPath(PropertyPath.TopicVariable(outputResultVariable), isInitializer: false) : null, + Messages = outputMessagesVariable is not null ? new InitializablePropertyPath(PropertyPath.TopicVariable(outputMessagesVariable), isInitializer: false) : null, }; } @@ -1414,5 +2096,23 @@ private InvokeFunctionTool CreateModelWithVariableArgument( return AssignParent(builder); } + private InvokeFunctionTool CreateModelWithVariableRequireApproval( + string displayName, string functionName, string requireApprovalVariableName, string outputResultVariable) + { + InvokeFunctionTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)), + RequireApproval = new BoolExpression.Builder( + BoolExpression.Variable(PropertyPath.TopicVariable(requireApprovalVariableName))), + Output = new InvokeToolOutput.Builder + { + Result = new InitializablePropertyPath(PropertyPath.TopicVariable(outputResultVariable), isInitializer: false), + }, + }; + return AssignParent(builder); + } + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index fa1ce7b974f..eb4e9589a55 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -117,17 +117,7 @@ internal async Task Test_RunSample_Step5Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment()); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: GetGuessResponse, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -139,17 +129,7 @@ internal async Task Test_RunSample_Step5aAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: GetGuessResponse, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -161,21 +141,11 @@ internal async Task Test_RunSample_Step5bAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - VerifyingPlaybackResponder responder = new( - // Iteration 1 - ("Guess the number.", 50), - ("Your guess was too high. Try again.", 23), - - // Iteration 2 - ("Your guess was too high. Try again.", 23), - ("Your guess was too low. Try again.", 42) - ); - JsonSerializerOptions options = new(SampleJsonContext.Default.Options); options.MakeReadOnly(); CheckpointManager memoryJsonManager = CheckpointManager.CreateJson(new InMemoryJsonStore(), options); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: GetGuessResponse, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); Assert.Equal("You guessed correctly! You Win!", guessResult); } @@ -555,6 +525,15 @@ internal async Task Test_RunSample_Step14a_SharedState_IsolatedAcrossSubworkflow Assert.IsType(actualError); } + + private static int GetGuessResponse(string prompt) => + prompt switch + { + "Guess the number." => 50, + "Your guess was too high. Try again." => 23, + "Your guess was too low. Try again." => 42, + _ => throw new InvalidOperationException($"Unexpected guess prompt: {prompt}") + }; } internal sealed class VerifyingPlaybackResponder diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs new file mode 100644 index 00000000000..cae9a7f6594 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestExternalEnvelopes.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal sealed class TestExternalRequestEnvelope : IExternalRequestEnvelope +{ + [JsonConstructor] + public TestExternalRequestEnvelope(FunctionCallContent functionCall) + { + this.FunctionCall = functionCall; + } + + public FunctionCallContent FunctionCall { get; } + + AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => this.FunctionCall; + + object IExternalRequestEnvelope.CreateResponse(IList messages) => messages; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs index aaba9423d7d..db8e8eda3a8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs @@ -7,5 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; // Checkpointing Types [JsonSerializable(typeof(TestJsonSerializable))] +[JsonSerializable(typeof(TestExternalRequestEnvelope))] [ExcludeFromCodeCoverage] internal sealed partial class TestJsonContext : JsonSerializerContext; From c760fc69e7d7069ce4b0ef6966de08ed46f27c36 Mon Sep 17 00:00:00 2001 From: Sunhy <58774225+Shy7777@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:23:26 +0000 Subject: [PATCH 15/42] Python: preserve integer precision in in-memory Hamming search (#8637) --- .../core/agent_framework/_in_memory.py | 9 +++-- .../core/tests/core/test_in_memory.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_in_memory.py b/python/packages/core/agent_framework/_in_memory.py index 01b5ef6d448..cc8f8046cb7 100644 --- a/python/packages/core/agent_framework/_in_memory.py +++ b/python/packages/core/agent_framework/_in_memory.py @@ -96,7 +96,7 @@ def _numeric_vector(value: Any, *, field_name: str) -> tuple[float, ...]: number = float(item) if not math.isfinite(number): raise ValueError(f"Vector field '{field_name}' must contain only finite numbers.") - vector.append(number) + vector.append(item) if not vector: raise ValueError(f"Vector field '{field_name}' cannot be empty.") return tuple(vector) @@ -115,6 +115,11 @@ def _paired_vectors(left: Vector, right: Vector) -> tuple[tuple[float, ...], tup def _calculate_score(left: Vector, right: Vector, distance_function: DistanceFunction) -> float: left_values, right_values = _paired_vectors(left, right) + if distance_function == "hamming": + # Compare coordinates before float conversion can round distinct large integers to the same value. + return sum(a != b for a, b in zip(left_values, right_values, strict=True)) / len(left_values) + left_values = tuple(float(value) for value in left_values) + right_values = tuple(float(value) for value in right_values) if distance_function in ("cosine_similarity", "cosine_distance", "DEFAULT"): left_scale = max(abs(value) for value in left_values) right_scale = max(abs(value) for value in right_values) @@ -140,8 +145,6 @@ def _calculate_score(left: Vector, right: Vector, distance_function: DistanceFun score = sum((a - b) * (a - b) for a, b in zip(left_values, right_values, strict=True)) elif distance_function == "manhattan": score = sum(abs(a - b) for a, b in zip(left_values, right_values, strict=True)) - elif distance_function == "hamming": - score = sum(a != b for a, b in zip(left_values, right_values, strict=True)) / len(left_values) else: raise NotImplementedError(f"Distance function '{distance_function}' is not supported by InMemoryCollection.") if not math.isfinite(score): diff --git a/python/packages/core/tests/core/test_in_memory.py b/python/packages/core/tests/core/test_in_memory.py index 6477548c689..1253a189519 100644 --- a/python/packages/core/tests/core/test_in_memory.py +++ b/python/packages/core/tests/core/test_in_memory.py @@ -531,6 +531,39 @@ async def test_hamming_scores_and_thresholds_use_mismatch_proportions() -> None: ] +@pytest.mark.parametrize( + ("vector_type", "coordinate"), + [("int64", 2**53), ("int64", -(2**53) - 1), ("uint64", 2**64 - 2)], +) +@pytest.mark.parametrize("threshold", [None, 0.0]) +async def test_hamming_preserves_large_integer_coordinates( + vector_type: str, coordinate: int, threshold: float | None +) -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", type_=vector_type, dimensions=2, distance_function="hamming"), + ], + collection_name="integer-hamming", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert( + [ + {"id": "different", "vector": [coordinate + 1, 0]}, + {"id": "same", "vector": [coordinate, 0]}, + ], + generate_vectors=False, + ) + + results = await collection.search(vector=[coordinate, 0], score_threshold=threshold) + + expected = [("same", 0.0)] + if threshold is None: + expected.append(("different", 0.5)) + assert [(result["record"]["id"], result["score"]) async for result in results] == expected + + @pytest.mark.parametrize("contents", ["empty", "missing_vector", "filtered_out"]) @pytest.mark.parametrize("score_threshold", [None, 0.5]) async def test_unsupported_distance_is_rejected_independently_of_records( From a8781d7bdfeba8408a31547fcd8418d95f1f50fa Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 22 Sep 2026 14:57:12 +0000 Subject: [PATCH 16/42] Python: add non-blocking Python 3.15 CI (#8652) --- .github/workflows/python-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4ccfe254ae0..56f8b92e5b1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -17,10 +17,11 @@ jobs: python-tests: name: Python Tests runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.python-version == '3.15' }} strategy: fail-fast: true matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] # todo: add macos-latest when problems are resolved os: [ubuntu-latest, windows-latest] env: From 925f4f2c6c494eba2779f7f79de1ca95958f6d8a Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 22 Sep 2026 15:03:56 +0000 Subject: [PATCH 17/42] .NET: ci/dotnet vscode configuration (#8537) * ci: selects the slnf file for the symbol server in vscode ci: use the slnf file for dotnet build tasks Signed-off-by: Vincent Biret * ci: adds test projects in the slnf file Signed-off-by: Vincent Biret * Fix build task configuration in tasks.json Updated build task to reference the correct solution file and change the working directory. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Rename source and test solution filter Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Signed-off-by: Vincent Biret Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .vscode/settings.json | 3 + .vscode/tasks.json | 16 +++++ dotnet/agent-framework-release.slnf | 43 ------------- dotnet/agent-framework-src-and-tests.slnf | 75 +++++++++++++++++++++++ 4 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json delete mode 100644 dotnet/agent-framework-release.slnf create mode 100644 dotnet/agent-framework-src-and-tests.slnf diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..3f312ba1bbe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.defaultSolution": "dotnet/agent-framework-src-and-tests.slnf" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..69a854bebb3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,16 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "process", + "command": "dotnet", + "group": "build", + "problemMatcher": [], + "label": "dotnet: build source and test solution filter", + "args": ["build", "agent-framework-src-and-tests.slnf"], + "options": { + "cwd": "${workspaceFolder}/dotnet" + } + } + ] +} diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf deleted file mode 100644 index 4588c11d0e6..00000000000 --- a/dotnet/agent-framework-release.slnf +++ /dev/null @@ -1,43 +0,0 @@ -{ - "solution": { - "path": "agent-framework-dotnet.slnx", - "projects": [ - "src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj", - "src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj", - "src\\Microsoft.Agents.AI.AgentHooks\\Microsoft.Agents.AI.AgentHooks.csproj", - "src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj", - "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj", - "src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj", - "src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj", - "src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj", - "src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj", - "src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj", - "src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj", - "src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj", - "src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj", - - "src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj", - "src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj", - "src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj", - "src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj", - "src\\Microsoft.Agents.AI.Hosting.AzureStorage\\Microsoft.Agents.AI.Hosting.AzureStorage.csproj", - "src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj", - "src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj", - "src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj", - "src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj", - "src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj", - "src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj", - "src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj", - "src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj", - "src\\Microsoft.Agents.AI.Valkey\\Microsoft.Agents.AI.Valkey.csproj", - "src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj", - "src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj", - "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj", - "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", - "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", - "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj", - "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj", - "src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj" - ] - } -} diff --git a/dotnet/agent-framework-src-and-tests.slnf b/dotnet/agent-framework-src-and-tests.slnf new file mode 100644 index 00000000000..b6639c7a09b --- /dev/null +++ b/dotnet/agent-framework-src-and-tests.slnf @@ -0,0 +1,75 @@ +{ + "solution": { + "path": "agent-framework-dotnet.slnx", + "projects": [ + "src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj", + "src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj", + "src\\Microsoft.Agents.AI.AgentHooks\\Microsoft.Agents.AI.AgentHooks.csproj", + "src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj", + "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj", + "src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj", + "src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj", + "src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj", + "src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj", + "src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj", + "src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj", + "src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj", + "src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj", + + "src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj", + "src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj", + "src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj", + "src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj", + "src\\Microsoft.Agents.AI.Hosting.AzureStorage\\Microsoft.Agents.AI.Hosting.AzureStorage.csproj", + "src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj", + "src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj", + "src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj", + "src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj", + "src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj", + "src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj", + "src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj", + "src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj", + "src\\Microsoft.Agents.AI.Valkey\\Microsoft.Agents.AI.Valkey.csproj", + "src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj", + "src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj", + "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj", + "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", + "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", + "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj", + "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj", + "src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj", + "tests\\Aspire.Hosting.AgentFramework.DevUI.UnitTests\\Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.A2A.UnitTests\\Microsoft.Agents.AI.A2A.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Abstractions.UnitTests\\Microsoft.Agents.AI.Abstractions.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.AgentHooks.UnitTests\\Microsoft.Agents.AI.AgentHooks.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Anthropic.UnitTests\\Microsoft.Agents.AI.Anthropic.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.AzureAI.Persistent.UnitTests\\Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.CosmosNoSql.UnitTests\\Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Declarative.UnitTests\\Microsoft.Agents.AI.Declarative.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.DevUI.UnitTests\\Microsoft.Agents.AI.DevUI.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.FeatureRegistry.UnitTests\\Microsoft.Agents.AI.FeatureRegistry.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Foundry.Hosting.UnitTests\\Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Foundry.UnitTests\\Microsoft.Agents.AI.Foundry.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.GitHub.Copilot.UnitTests\\Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Harness.UnitTests\\Microsoft.Agents.AI.Harness.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Hosting.A2A.UnitTests\\Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests\\Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Hosting.OpenAI.UnitTests\\Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Hosting.UnitTests\\Microsoft.Agents.AI.Hosting.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Hyperlight.UnitTests\\Microsoft.Agents.AI.Hyperlight.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.LocalCodeAct.UnitTests\\Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Mcp.UnitTests\\Microsoft.Agents.AI.Mcp.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Mem0.UnitTests\\Microsoft.Agents.AI.Mem0.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.OpenAI.UnitTests\\Microsoft.Agents.AI.OpenAI.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Purview.UnitTests\\Microsoft.Agents.AI.Purview.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Tools.Shell.UnitTests\\Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.UnitTests\\Microsoft.Agents.AI.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Valkey.UnitTests\\Microsoft.Agents.AI.Valkey.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Workflows.Declarative.UnitTests\\Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Workflows.Generators.UnitTests\\Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj", + "tests\\Microsoft.Agents.AI.Workflows.UnitTests\\Microsoft.Agents.AI.Workflows.UnitTests.csproj" + ] + } +} From 9ad34d9fcf18246fed631d1ec9af1e1e7ca662e9 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:14:44 +0000 Subject: [PATCH 18/42] Python: Support short flags in docker command (#8644) * Support short flags in docker command * Address PR comments --- .../agent_framework_tools/shell/_docker.py | 112 +++++++++- .../tools/tests/test_docker_shell_tool.py | 199 ++++++++++++++++++ 2 files changed, 303 insertions(+), 8 deletions(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_docker.py b/python/packages/tools/agent_framework_tools/shell/_docker.py index c83aa1318f3..8a7646f77d2 100644 --- a/python/packages/tools/agent_framework_tools/shell/_docker.py +++ b/python/packages/tools/agent_framework_tools/shell/_docker.py @@ -92,13 +92,99 @@ "--ipc", "--userns", "--user", + "-u", "--cgroupns", "--add-host", "--gpus", "--read-only", "--tmpfs", + "--memory", + "-m", + "--memory-swap", + "--pids-limit", ) +# ``docker run`` long options that take no value. Everything else is assumed to +# consume the following token when its value is not attached with "=", so a +# detached value that happens to start with a dash ("--env-file -vars.env") is +# not mistaken for an option. Booleans are the smaller and more stable half of +# the flag table, and assuming an unknown flag takes a value keeps the +# validator from rejecting configurations that docker accepts. +_VALUELESS_LONG_FLAGS: frozenset[str] = frozenset({ + "--detach", + "--disable-content-trust", + "--help", + "--init", + "--interactive", + "--no-healthcheck", + "--oom-kill-disable", + "--privileged", + "--publish-all", + "--quiet", + "--read-only", + "--rm", + "--sig-proxy", + "--tty", + "--use-api-socket", +}) + +# ``docker run`` short options, split by whether they consume a value. Docker's +# flag parser lets boolean shorthands be clustered ("-it") and lets a +# value-taking shorthand carry its value attached to it, so "-v/:/host:rw" is +# "-v /:/host:rw" and "-u0:0" is "--user 0:0". Resolving which shorthands a +# single-dash token actually sets therefore needs both sets: scanning stops at +# the first value-taking shorthand because the rest of the token is its value. +_BOOLEAN_SHORT_FLAGS = frozenset("diPqt") +_VALUE_SHORT_FLAGS = frozenset("acehlmpuvw") + + +def _short_flags_in_token(token: str) -> list[str]: + """Expand a single-dash token into the short flags it sets.""" + flags: list[str] = [] + for char in token[1:]: + if char in _BOOLEAN_SHORT_FLAGS: + flags.append(f"-{char}") + continue + if char in _VALUE_SHORT_FLAGS: + flags.append(f"-{char}") + # Everything after a value-taking shorthand is its value. + break + # Unknown shorthand: stop rather than misread a value as more flags. + break + return flags + + +def _blocked_flags_in_token(raw: str) -> list[str]: + """Return the blocked flags a single ``docker run`` token resolves to.""" + # Split off any "=value" tail so "--network=host" matches "--network". + flag = raw.split("=", 1)[0] + if flag.startswith("--"): + return [flag] if flag in _BLOCKED_EXTRA_RUN_FLAGS else [] + return [short for short in _short_flags_in_token(flag) if short in _BLOCKED_EXTRA_RUN_FLAGS] + + +def _consumes_next_token(raw: str) -> bool: + """Return whether this token takes the following token as its value. + + Needed so a detached value is not decoded as an option: docker accepts + ``--env-file -vars.env``, where ``-vars.env`` is a filename rather than + ``-v``. + """ + name, sep, _ = raw.partition("=") + if sep: + # Value is attached, e.g. "--network=host". + return False + if name.startswith("--"): + # pflag booleans only accept "--flag=false", never "--flag false". + return name not in _VALUELESS_LONG_FLAGS + shorts = _short_flags_in_token(name) + if not shorts: + return False + if len(name) - 1 > len(shorts): + # Trailing text is the value, e.g. "-v/:/host:rw" or "-m0". + return False + return shorts[-1][1] in _VALUE_SHORT_FLAGS + def _validate_extra_run_args(args: Sequence[str]) -> None: """Reject extra ``docker run`` args that would break the isolation contract. @@ -110,13 +196,19 @@ def _validate_extra_run_args(args: Sequence[str]) -> None: build their own argv rather than slip past the check. """ bad: list[str] = [] - for raw in args: - if not raw.startswith("-"): + index = 0 + while index < len(args): + raw = args[index] + index += 1 + # "-" is a conventional positional, and "--" only ends option parsing; + # neither takes a value, and args after "--" are still worth checking + # so a blocked flag cannot hide behind the marker. + if raw in ("-", "--") or not raw.startswith("-"): continue - # Split off any "=value" tail so "--network=host" matches "--network". - flag = raw.split("=", 1)[0] - if flag in _BLOCKED_EXTRA_RUN_FLAGS: + if _blocked_flags_in_token(raw): bad.append(raw) + if _consumes_next_token(raw): + index += 1 if bad: raise ValueError( "extra_run_args contains flags that would dismantle DockerShellTool's " @@ -297,10 +389,14 @@ class DockerShellTool: (``--privileged``, ``--cap-add``, ``--security-opt``, ``--network``/``--net``, ``-v``/``--volume``, ``--mount``, ``--device``, ``--pid``, ``--ipc``, - ``--userns``, ``--user``, ``--read-only``, + ``--userns``, ``-u``/``--user``, ``--read-only``, ``--tmpfs``, ``--add-host``, ``--gpus``, ``--cgroupns``, - ``--device-cgroup-rule``) are rejected at construction - time. Override the corresponding dedicated argument + ``--device-cgroup-rule``, ``-m``/``--memory``, + ``--memory-swap``, ``--pids-limit``) are rejected at + construction time, including when a short flag carries + its value attached to it (``-v/:/host:rw``) or is + clustered with other short flags (``-itv/:/host:rw``). + Override the corresponding dedicated argument (``network``, ``host_workdir``, ``mount_readonly``, ``read_only_root``, ``user``, etc.) instead. If you genuinely need to relax the sandbox further, subclass diff --git a/python/packages/tools/tests/test_docker_shell_tool.py b/python/packages/tools/tests/test_docker_shell_tool.py index e9f63fa4070..00aed768f2a 100644 --- a/python/packages/tools/tests/test_docker_shell_tool.py +++ b/python/packages/tools/tests/test_docker_shell_tool.py @@ -29,6 +29,11 @@ is_docker_available, ) from agent_framework_tools.shell._docker import ( + _BLOCKED_EXTRA_RUN_FLAGS, + _BOOLEAN_SHORT_FLAGS, + _VALUE_SHORT_FLAGS, + _VALUELESS_LONG_FLAGS, + _consumes_next_token, build_exec_argv, build_run_argv, ) @@ -194,6 +199,12 @@ def test_build_run_argv_passes_extra_env_and_args(): image_idx = argv.index("alpine") assert "--label" in argv[:image_idx] assert "team=af" in argv[:image_idx] + # ...and after every enforced isolation default, so docker's last-flag-wins + # never lets an extra arg be overridden by one of the tool's own flags. + assert argv.index("--label") > max( + argv.index(flag) + for flag in ("--user", "--network", "--memory", "--pids-limit", "--cap-drop", "--security-opt", "--read-only") + ) def test_build_exec_argv_interactive(): @@ -226,6 +237,32 @@ def test_build_exec_argv_interactive(): ("--gpus", "all"), ("--add-host", "evil:1.2.3.4"), ("--label", "x=1", "--privileged"), # mixed safe + unsafe + # Short flags carrying an attached value: docker parses these the same + # as the space-separated form, so they must be rejected too. + ("-v/:/host:rw",), + ("-v/var/run/docker.sock:/var/run/docker.sock",), + ("-v=/etc:/etc",), + ("-u0:0",), + # The -u alias of --user, both spellings. + ("-u", "0:0"), + ("-u=0:0",), + # Blocked short flag clustered behind boolean short flags. + ("-itv/:/host:rw",), + ("-itu0:0",), + # Resource caps the tool sets as isolation defaults: overriding these + # silently removes the memory/pids limits. + ("-m0",), + ("-m", "0"), + ("--memory=0",), + ("--memory", "0"), + ("--memory-swap=-1",), + ("--pids-limit=-1",), + ("--pids-limit", "-1"), + ("-m0", "--pids-limit=-1"), + # A blocked flag after a token that consumes nothing is still caught. + ("--", "-u0:0"), + ("-it", "-u0:0"), + ("--privileged", "-v/:/host:rw"), ], ) def test_dockershell_rejects_isolation_breaking_extra_run_args(extra): @@ -233,11 +270,153 @@ def test_dockershell_rejects_isolation_breaking_extra_run_args(extra): DockerShellTool(extra_run_args=list(extra)) +def test_dockershell_rejection_message_reports_the_raw_token(): + with pytest.raises(ValueError, match=r"-v/:/host:rw"): + DockerShellTool(extra_run_args=("-v/:/host:rw",)) + + def test_dockershell_accepts_benign_extra_run_args(): # Should not raise. DockerShellTool(extra_run_args=("--label", "team=af", "--name-suffix", "x")) +@pytest.mark.parametrize( + "extra", + [ + # Long flags that merely share a prefix with a blocked flag. + ("--userland-proxy=false",), + ("--volumes-from", "other"), + ("--network-alias", "svc"), + # Value-taking short flags that are not blocked, including attached + # values whose text contains a blocked flag's letter. + ("-l", "team=af"), + ("-lversion=1",), + ("-e", "USER=root"), + ("-eversion=1",), + ("-w/workspace",), + ("-it",), + # End-of-options marker and a bare dash. + ("--",), + ("-",), + # Detached values that start with a dash belong to the preceding + # option and must not be decoded as options themselves. + ("--env-file", "-variables.env"), + ("--name", "-upper"), + ("--label", "-v/:/host:rw"), + ("--entrypoint", "-v"), + ("-e", "-value"), + ("--hostname", "-unusual"), + # Attached values on non-blocked options are likewise not options. + ("--env-file=-variables.env",), + ], +) +def test_dockershell_accepts_extra_run_args_that_are_not_blocked(extra): + # Should not raise: normalization must not over-reject. + DockerShellTool(extra_run_args=list(extra)) + + +# ``docker run --help`` short options, transcribed so the blocklist can be +# audited without a docker daemon. Keep in sync when bumping the supported +# docker/podman baseline. +_DOCKER_RUN_SHORT_ALIASES = { + "--attach": "-a", + "--cpu-shares": "-c", + "--detach": "-d", + "--env": "-e", + "--hostname": "-h", + "--interactive": "-i", + "--label": "-l", + "--memory": "-m", + "--publish": "-p", + "--publish-all": "-P", + "--quiet": "-q", + "--tty": "-t", + "--user": "-u", + "--volume": "-v", + "--workdir": "-w", +} + + +def test_blocked_flags_cover_every_docker_short_alias(): + """Any blocked long flag with a short alias must block the alias too. + + Guards against a blocked flag being added without its short form, which + would let the alias slip past the isolation check. + """ + missing = [ + long_flag + for long_flag, short in _DOCKER_RUN_SHORT_ALIASES.items() + if long_flag in _BLOCKED_EXTRA_RUN_FLAGS and short not in _BLOCKED_EXTRA_RUN_FLAGS + ] + assert not missing, f"blocked flags missing their docker short alias: {missing}" + + +def test_short_flag_tables_match_docker_run_options(): + """The short-flag tables must classify exactly docker run's shorthands. + + A missing entry would let a blocked shorthand hide inside a cluster; a + spurious one would misread an attached value as further flags. + """ + docker_shorts = set(_DOCKER_RUN_SHORT_ALIASES.values()) + modelled = {f"-{c}" for c in _BOOLEAN_SHORT_FLAGS | _VALUE_SHORT_FLAGS} + assert modelled == docker_shorts + # A shorthand cannot be both boolean and value-taking. + assert not (_BOOLEAN_SHORT_FLAGS & _VALUE_SHORT_FLAGS) + + +# ``docker run --help`` long options that take no value, transcribed for the +# same reason as the short aliases above. +_DOCKER_RUN_BOOLEAN_FLAGS = frozenset({ + "--detach", + "--help", + "--init", + "--interactive", + "--no-healthcheck", + "--oom-kill-disable", + "--privileged", + "--publish-all", + "--quiet", + "--read-only", + "--rm", + "--sig-proxy", + "--tty", + "--use-api-socket", +}) + + +def test_valueless_long_flags_covers_every_docker_boolean(): + """A boolean flag missing from the table would swallow the next token. + + That would let ``("--some-bool", "-u0:0")`` hide a blocked flag, so the + table must list every valueless long option. + """ + missing = _DOCKER_RUN_BOOLEAN_FLAGS - _VALUELESS_LONG_FLAGS + assert not missing, f"boolean flags missing from _VALUELESS_LONG_FLAGS: {sorted(missing)}" + + +@pytest.mark.parametrize( + ("token", "consumes"), + [ + # Long options: "=" attaches the value, otherwise arity decides. + ("--env-file", True), + ("--env-file=vars.env", False), + ("--privileged", False), + ("--read-only", False), + ("--network", True), + # Short options: an attached value means the next token is unrelated. + ("-v", True), + ("-v/:/host:rw", False), + ("-m", True), + ("-m0", False), + ("-it", False), + ("-itv", True), + ("-e", True), + ], +) +def test_consumes_next_token(token, consumes): + assert _consumes_next_token(token) is consumes + + def test_build_exec_argv_non_interactive_appends_dash_c(): argv = build_exec_argv(binary="docker", container_name="c", interactive=False) assert argv == ["docker", "exec", "-i", "c", "bash", "-c"] @@ -411,6 +590,26 @@ async def test_run_stateless_builds_expected_argv() -> None: assert result.timed_out is False +async def test_run_stateless_places_extra_args_after_isolation_defaults() -> None: + tool = DockerShellTool(mode="stateless", image="alpine:3", shell="sh", extra_run_args=("--label", "team=af")) + proc = _FakeProcess(returncode=0, communicate_results=[(b"", b"")]) + + with patch( + "agent_framework_tools.shell._docker.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ) as create_proc: + await tool._run_stateless("echo hi", timeout=5.0) + + assert create_proc.await_args is not None + argv = create_proc.await_args.args + assert argv[-4:] == ("alpine:3", "sh", "-c", "echo hi") + assert argv.index("--label") > max( + argv.index(flag) + for flag in ("--user", "--network", "--memory", "--pids-limit", "--cap-drop", "--security-opt", "--read-only") + ) + assert argv.index("--label") < argv.index("alpine:3") + + async def test_run_stateless_timeout_reaps_container_when_kill_fails() -> None: tool = DockerShellTool(mode="stateless") command_proc = _FakeProcess( From 646e116a4099a6dc0f689ff2dd8b41b31ba2bdae Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:41:26 +0000 Subject: [PATCH 19/42] .NET: [BREAKING] Fix DevUI approval continuation (#8423) * fix(hosting): resume trusted function approvals Recognize DevUI function approval responses, persist agent sessions across response continuations, and reject approval responses when no session store is configured. Refs #6006 * fix(hosting): reject invalid approval continuations Track approval request IDs emitted by Responses and reject unknown or cross-session approval responses before agent execution. Refs #6006 * fix(hosting): require explicit approval decisions Reject function approval responses that omit the required approved Boolean instead of interpreting the missing value as a rejection. Refs #6006 * fix(hosting): reject malformed approval arguments Require function approval arguments to be a JSON object, return request binding errors for invalid shapes, and emit an empty object for no-argument calls. Refs #6006 * fix(hosting): preserve background session isolation Capture the trusted caller isolation key before response execution detaches from the HTTP request and bind background session operations to that value. Refs #6006 * fix(hosting): persist transient agent sessions Separate session storage identity from agent instance identity so request-routed transient agents can restore sessions without changing AIAgent.Id or AIAgent.Name. Refs #6006 * fix(hosting): honor scoped response execution * test(hosting): define streaming approval continuation * fix(hosting): preserve nested session isolation --- .../src/Microsoft.Agents.AI.DevUI/README.md | 36 ++ ...ndpointRouteBuilderExtensions.Responses.cs | 42 +- .../OpenAIHostingJsonUtilities.cs | 1 + .../Responses/AIAgentResponseExecutor.cs | 85 ++- .../Responses/AgentInvocationContext.cs | 13 +- .../Responses/AgentResponseExecution.cs | 248 ++++++++ .../Converters/ItemContentConverter.cs | 29 + .../Responses/HostedAgentResponseExecutor.cs | 140 ++-- .../Responses/InMemoryResponsesService.cs | 36 +- .../Responses/Models/ItemResource.cs | 43 ++ .../FunctionApprovalRequestEventGenerator.cs | 4 +- .../ServiceCollectionExtensions.cs | 7 + .../AIHostAgent.cs | 76 ++- .../IsolationKeyScopedAgentSessionStore.cs | 41 +- .../Local/InMemoryAgentSessionStore.cs | 16 +- .../FunctionApprovalTests.cs | 456 +++++++++++++ .../OpenAIResponsesIsolationTests.cs | 106 ++- .../ResponseSessionRegressionTests.cs | 601 ++++++++++++++++++ .../InMemoryAgentSessionStoreTests.cs | 28 + ...solationKeyScopedAgentSessionStoreTests.cs | 119 ++++ 20 files changed, 2009 insertions(+), 118 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseExecution.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md index ba9931e5d12..8b4643fa419 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md @@ -52,6 +52,42 @@ if (builder.Environment.IsDevelopment()) app.Run(); ``` +## Function approvals + +Function approval requires an agent session store. The approval request and the user's +decision arrive in separate HTTP requests. The store preserves the server-recorded +request so the decision can be matched to the exact function call shown in DevUI, +rather than trusting function details supplied by the caller. + +Configure a session store on every agent that exposes an +`ApprovalRequiredAIFunction`: + +```csharp +builder.AddAIAgent("assistant", "You are a helpful assistant.") + .WithInMemorySessionStore(); +``` + +`WithInMemorySessionStore()` preserves approvals between requests but loses them +when the application restarts. Use `WithSessionStore(...)` with persistent storage +when approvals must survive restarts or move between service instances. + +If a client sends `function_approval_response` for an agent without a configured +session store, the Responses endpoint returns HTTP 400: + +```text +Approval-required function calling is not supported because no AgentSessionStore is configured. +``` + +The endpoint also returns HTTP 400 when `request_id` is unknown or belongs to a +different stored session. This prevents an approval issued in one conversation +from authorizing a function call in another. + +For a streaming response, wait for its terminal event before sending the +`function_approval_response`. The server persists the approval checkpoint before +publishing `response.completed`. A decision sent while the originating stream is +still in progress is rejected with HTTP 400 because that approval is not yet +available for continuation. + ## Security DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that: diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index 561504c58ba..32a9e502a6b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -38,8 +38,21 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agentBuilder); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); - return MapOpenAIResponses(endpoints, agent, path, mapOptions); + ValidateAgentName(agentBuilder.Name); + path ??= $"/{agentBuilder.Name}/v1/responses"; + + // Defer agent and session-store resolution so their registered lifetimes are owned by + // each validation or execution operation rather than by the application root. + var scopeFactory = endpoints.ServiceProvider.GetRequiredService(); + var executor = new AIAgentResponseExecutor( + agentBuilder.Name, + scopeFactory, + mapOptions); + return MapOpenAIResponses( + endpoints, + executor, + path, + agentBuilder.Name); } /// @@ -70,9 +83,27 @@ public static IEndpointConventionBuilder MapOpenAIResponses( responsesPath ??= $"/{agent.Name}/v1/responses"; - // Create an executor for this agent - var executor = new AIAgentResponseExecutor(agent, mapOptions); + // The supplied agent remains fixed, while its optional keyed session store is resolved + // inside each operation scope. + var scopeFactory = endpoints.ServiceProvider.GetRequiredService(); + var executor = new AIAgentResponseExecutor( + agent, + agent.Name, + scopeFactory, + mapOptions); + return MapOpenAIResponses( + endpoints, + executor, + responsesPath, + agent.Name); + } + private static RouteGroupBuilder MapOpenAIResponses( + IEndpointRouteBuilder endpoints, + IResponseExecutor executor, + string responsesPath, + string endpointAgentName) + { // Resolve the response storage settings and optional conversation storage. var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions(); var conversationStorage = endpoints.ServiceProvider.GetService(); @@ -85,11 +116,8 @@ public static IEndpointConventionBuilder MapOpenAIResponses( // Create the response service so response and conversation operations are scoped by the caller's isolation key. var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage, isolationKeyResolver); - var handlers = new ResponsesHttpHandler(responsesService); - var group = endpoints.MapGroup(responsesPath); - var endpointAgentName = agent.Name ?? agent.Id; // Create response endpoint group.MapPost("/", handlers.CreateResponseAsync) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs index f77143c5832..60c6c916fd9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs @@ -147,6 +147,7 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(ItemContentOutputText))] [JsonSerializable(typeof(ItemContentOutputAudio))] [JsonSerializable(typeof(ItemContentRefusal))] +[JsonSerializable(typeof(ItemContentFunctionApprovalResponse))] [JsonSerializable(typeof(TextConfiguration))] [JsonSerializable(typeof(ResponseTextFormatConfiguration))] [JsonSerializable(typeof(ResponseTextFormatConfigurationText))] diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index 5b01cc1394a..84e95d7f0d7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; @@ -16,22 +17,56 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; /// internal sealed class AIAgentResponseExecutor : IResponseExecutor { - private readonly AIAgent _agent; + private readonly AIAgent? _agent; + private readonly string _registrationKey; + private readonly IServiceScopeFactory _scopeFactory; private readonly OpenAIResponsesMapOptions _mapOptions; public AIAgentResponseExecutor( AIAgent agent, + string registrationKey, + IServiceScopeFactory scopeFactory, OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrEmpty(registrationKey); + ArgumentNullException.ThrowIfNull(scopeFactory); + this._agent = agent; + this._registrationKey = registrationKey; + this._scopeFactory = scopeFactory; + this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); + } + + public AIAgentResponseExecutor( + string registrationKey, + IServiceScopeFactory scopeFactory, + OpenAIResponsesMapOptions? mapOptions = null) + { + ArgumentException.ThrowIfNullOrEmpty(registrationKey); + ArgumentNullException.ThrowIfNull(scopeFactory); + + this._registrationKey = registrationKey; + this._scopeFactory = scopeFactory; this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } - public ValueTask ValidateRequestAsync( + public async ValueTask ValidateRequestAsync( CreateResponse request, CancellationToken cancellationToken = default) - => ValueTask.FromResult(this.ValidateRunOptions(request)); + { + AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + AIAgent executionAgent = this.ResolveExecutionAgent(scope.ServiceProvider); + ResponseError? sessionError = + AgentResponseExecution.ValidateSessionRequirements(request, executionAgent is AIHostAgent); + return sessionError + ?? this.ValidateRunOptions(request) + ?? await AgentResponseExecution.ValidatePendingApprovalResponsesAsync( + executionAgent, request, cancellationToken).ConfigureAwait(false); + } + } internal ResponseError? ValidateRunOptions(CreateResponse request) { @@ -58,29 +93,37 @@ public async IAsyncEnumerable ExecuteAsync( IReadOnlyList? conversationHistory = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any) - // request settings are mapped onto the agent run. By default no request setting is mapped. - AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); - - // Convert input to chat messages, prepending conversation history if available - var messages = new List(); - - if (conversationHistory is not null) + // The scope surrounds the full enumeration so background execution retains scoped + // agents and stores through session persistence after the HTTP request has returned. + AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) { - messages.AddRange(conversationHistory); - } + AIAgent executionAgent = this.ResolveExecutionAgent(scope.ServiceProvider); - foreach (var inputMessage in request.Input.GetInputMessages()) - { - messages.Add(inputMessage.ToChatMessage()); + // Agent selection is fixed by the endpoint. The remaining response execution behavior is + // shared with request-routed hosted agents. + await foreach (StreamingResponseEvent streamingEvent in AgentResponseExecution.ExecuteAsync( + executionAgent, this._mapOptions, context, request, conversationHistory, cancellationToken).ConfigureAwait(false)) + { + yield return streamingEvent; + } } + } - // Use the extension method to convert streaming updates to streaming response events - await foreach (var streamingEvent in this._agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken) - .ToStreamingResponseAsync(request, context, cancellationToken) - .ConfigureAwait(false)) + private AIAgent ResolveExecutionAgent(IServiceProvider services) + { + AIAgent agent = this._agent ?? + services.GetRequiredKeyedService(this._registrationKey); +#pragma warning disable MAAI001 + AgentSessionStore? sessionStore = + services.GetKeyedService(this._registrationKey); +#pragma warning restore MAAI001 + if (sessionStore is null) { - yield return streamingEvent; + return agent; } + + string? sessionStorageIdentity = this._agent is null ? this._registrationKey : null; + return new AIHostAgent(agent, sessionStore, sessionStorageIdentity); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs index f21c2e84e94..1fdd2bfb53f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs @@ -9,7 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; /// /// The ID generator. /// The JSON serializer options. If not provided, default options will be used. -internal sealed class AgentInvocationContext(IdGenerator idGenerator, JsonSerializerOptions? jsonSerializerOptions = null) +/// +/// The trusted caller isolation key captured while the originating request context was available. +/// +internal sealed class AgentInvocationContext( + IdGenerator idGenerator, + JsonSerializerOptions? jsonSerializerOptions = null, + string? isolationKey = null) { /// /// Gets the ID generator for this context. @@ -26,6 +32,11 @@ internal sealed class AgentInvocationContext(IdGenerator idGenerator, JsonSerial /// public string ConversationId => this.IdGenerator.ConversationId; + /// + /// Gets the trusted caller isolation key captured before background execution detached from the request. + /// + public string? IsolationKey { get; } = isolationKey; + /// /// Gets the JSON serializer options. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseExecution.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseExecution.cs new file mode 100644 index 00000000000..c4d0cd352aa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseExecution.cs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; + +/// +/// Executes a response after the endpoint-specific executor has selected an agent. +/// +internal static class AgentResponseExecution +{ + private const string SessionInitializedStateKey = "Microsoft.Agents.AI.Hosting.OpenAI.Responses.Initialized"; + private const string PendingApprovalRequestIdsStateKey = "Microsoft.Agents.AI.Hosting.OpenAI.Responses.PendingApprovalRequestIds"; + + /// + /// Validates that requests which resume an approval have server-side session storage available. + /// + public static ResponseError? ValidateSessionRequirements(CreateResponse request, bool hasSessionStore) + { + List approvalResponses = GetFunctionApprovalResponses(request); + if (approvalResponses.Count == 0) + { + return null; + } + + if (hasSessionStore) + { + return null; + } + + // Approval responses are trusted only when matched to a request recorded by the server. + // Without session storage, continuing would silently ignore the decision or trust caller data. + return new ResponseError + { + Code = ResponseErrorCodes.InvalidRequest, + Message = "Approval-required function calling is not supported because no AgentSessionStore is configured." + }; + } + + /// + /// Validates that every incoming approval response matches a request emitted for the restored session. + /// + public static async ValueTask ValidatePendingApprovalResponsesAsync( + AIAgent agent, + CreateResponse request, + CancellationToken cancellationToken) + { + List approvalResponses = GetFunctionApprovalResponses(request); + if (approvalResponses.Count == 0) + { + return null; + } + + // ValidateSessionRequirements ensures approval responses reach this point only through an AIHostAgent. + var hostAgent = (AIHostAgent)agent; + string? sessionId = request.Conversation?.Id ?? request.PreviousResponseId; + if (sessionId is null) + { + return InvalidApprovalRequest(approvalResponses[0].RequestId); + } + + AgentSession session = await hostAgent.GetOrCreateSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + session.StateBag.TryGetValue>(PendingApprovalRequestIdsStateKey, out var pendingRequestIds); + + foreach (ItemContentFunctionApprovalResponse approvalResponse in approvalResponses) + { + if (pendingRequestIds?.Exists( + requestId => string.Equals(requestId, approvalResponse.RequestId, StringComparison.Ordinal)) != true) + { + return InvalidApprovalRequest(approvalResponse.RequestId); + } + } + + return null; + } + + /// + /// Runs the selected agent and converts its updates to Responses API events. + /// + public static async IAsyncEnumerable ExecuteAsync( + AIAgent agent, + OpenAIResponsesMapOptions mapOptions, + AgentInvocationContext context, + CreateResponse request, + IReadOnlyList? conversationHistory = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which request + // settings are mapped onto the agent run. By default no request setting is mapped. + AgentRunOptions? options = mapOptions.RunOptionsFactory(request.ToRequestInfo()); + + // A response ID identifies an immutable continuation snapshot. A conversation ID identifies the + // mutable conversation head. A new response without either starts under its generated response ID. + AIHostAgent? hostAgent = agent as AIHostAgent; + if (hostAgent is not null && context.IsolationKey is not null) + { + // Background work cannot safely query IHttpContextAccessor after the request has ended. Bind the + // session store to the trusted key captured by InMemoryResponsesService before detaching. + hostAgent = hostAgent.BindIsolationKey(context.IsolationKey); + agent = hostAgent; + } + + AgentSession? session = null; + bool includeConversationHistory = true; + if (hostAgent is not null) + { + string sessionId = request.Conversation?.Id + ?? request.PreviousResponseId + ?? context.ResponseId; + session = await hostAgent.GetOrCreateSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + + // A new agent session must be seeded from an existing conversation transcript. Once initialized, + // the session owns both history and pending approvals, so replaying the transcript would duplicate + // messages and could resubmit approval content that was already processed. + includeConversationHistory = !session.StateBag.TryGetValue(SessionInitializedStateKey, out _); + session.StateBag.SetValue(SessionInitializedStateKey, bool.TrueString); + } + + // Convert input to chat messages, prepending conversation history only when it is not already + // represented by a restored agent session. + var messages = new List(); + if (includeConversationHistory && conversationHistory is not null) + { + messages.AddRange(conversationHistory); + } + + foreach (InputMessage inputMessage in request.Input.GetInputMessages()) + { + messages.Add(inputMessage.ToChatMessage()); + } + + // Convert streaming agent updates to Responses API events. For persisted sessions, hold the + // terminal event until the state has been saved. Otherwise a streaming client can immediately + // continue the response before the approval checkpoint is available. + StreamingResponseCompleted? completedEvent = null; + List? emittedApprovalRequestIds = null; + await foreach (StreamingResponseEvent streamingEvent in agent.RunStreamingAsync(messages, session, options, cancellationToken) + .ToStreamingResponseAsync(request, context, cancellationToken) + .ConfigureAwait(false)) + { + if (session is not null && streamingEvent is StreamingFunctionApprovalRequested approvalRequested) + { + (emittedApprovalRequestIds ??= []).Add(approvalRequested.RequestId); + } + + if (hostAgent is not null && streamingEvent is StreamingResponseCompleted completed) + { + completedEvent = completed; + continue; + } + + yield return streamingEvent; + } + + if (hostAgent is not null && session is not null) + { + UpdatePendingApprovalRequests(session, request, emittedApprovalRequestIds); + + // Response IDs are immutable snapshots and honor store=false. A conversation ID is a mutable + // head and always advances after a successful turn. + if (request.Store is not false) + { + await hostAgent.SaveSessionAsync(context.ResponseId, session, cancellationToken).ConfigureAwait(false); + } + + if (request.Conversation?.Id is { } conversationId) + { + await hostAgent.SaveSessionAsync(conversationId, session, cancellationToken).ConfigureAwait(false); + } + } + + // Publish completion only after persistence so an immediate continuation can restore the session. + if (completedEvent is not null) + { + yield return completedEvent; + } + } + + private static List GetFunctionApprovalResponses(CreateResponse request) + { + var approvalResponses = new List(); + foreach (InputMessage inputMessage in request.Input.GetInputMessages()) + { + if (inputMessage.Content.Contents is not { } contents) + { + continue; + } + + foreach (ItemContent content in contents) + { + if (content is ItemContentFunctionApprovalResponse approvalResponse) + { + approvalResponses.Add(approvalResponse); + } + } + } + + return approvalResponses; + } + + private static void UpdatePendingApprovalRequests( + AgentSession session, + CreateResponse request, + List? emittedRequestIds) + { + session.StateBag.TryGetValue>(PendingApprovalRequestIdsStateKey, out var pendingRequestIds); + pendingRequestIds ??= []; + + // A successfully processed response consumes its matching request ID. New approval events are then + // added so the saved session represents exactly the approvals the client can answer next. + foreach (ItemContentFunctionApprovalResponse approvalResponse in GetFunctionApprovalResponses(request)) + { + pendingRequestIds.RemoveAll(id => string.Equals(id, approvalResponse.RequestId, StringComparison.Ordinal)); + } + + if (emittedRequestIds is not null) + { + foreach (string requestId in emittedRequestIds) + { + if (!pendingRequestIds.Exists(id => string.Equals(id, requestId, StringComparison.Ordinal))) + { + pendingRequestIds.Add(requestId); + } + } + } + + if (pendingRequestIds.Count == 0) + { + session.StateBag.TryRemoveValue(PendingApprovalRequestIdsStateKey); + } + else + { + session.StateBag.SetValue(PendingApprovalRequestIdsStateKey, pendingRequestIds); + } + } + + private static ResponseError InvalidApprovalRequest(string requestId) => new() + { + Code = ResponseErrorCodes.InvalidRequest, + Message = $"Function approval response '{requestId}' does not match a pending approval request for this session." + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs index 2476ce2fbd3..f54ade45445 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Text.Json; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Extensions.AI; @@ -70,6 +72,15 @@ private static string MediaTypeToAudioFormat(string mediaType) => ItemContentOutputAudio outputAudio => new DataContent(outputAudio.Data, "audio/*"), + ItemContentFunctionApprovalResponse approvalResponse => + new ToolApprovalResponseContent( + approvalResponse.RequestId, + approvalResponse.Approved, + new FunctionCallContent( + approvalResponse.FunctionCall.Id, + approvalResponse.FunctionCall.Name, + ToFunctionArguments(approvalResponse.FunctionCall.Arguments))), + _ => null }; @@ -159,4 +170,22 @@ DataContent audioData when audioData.HasTopLevelMediaType("audio") => return null; } + + private static Dictionary? ToFunctionArguments(JsonElement arguments) + { + if (arguments.ValueKind == JsonValueKind.Null) + { + return null; + } + + // Keep each value as a cloned JsonElement so nested objects, arrays, numbers, booleans, + // and null values retain their original JSON representation. + var result = new Dictionary(); + foreach (JsonProperty property in arguments.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + return result; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs index 687c121be25..2115eb7cdde 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -19,82 +19,102 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; /// internal sealed class HostedAgentResponseExecutor : IResponseExecutor { - private readonly IServiceProvider _serviceProvider; + private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly OpenAIResponsesMapOptions _mapOptions; /// /// Initializes a new instance of the class. /// - /// The service provider used to resolve hosted agents. + /// The factory used to create a scope for each hosted-agent operation. /// The logger instance. /// Options controlling how incoming requests are mapped onto the agent run. public HostedAgentResponseExecutor( - IServiceProvider serviceProvider, + IServiceScopeFactory scopeFactory, ILogger logger, OpenAIResponsesMapOptions? mapOptions = null) { - ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(scopeFactory); ArgumentNullException.ThrowIfNull(logger); - this._serviceProvider = serviceProvider; + this._scopeFactory = scopeFactory; this._logger = logger; this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } /// - public ValueTask ValidateRequestAsync( + public async ValueTask ValidateRequestAsync( CreateResponse request, CancellationToken cancellationToken = default) { // Extract agent name from agent.name or model parameter - string? agentName = GetAgentName(request); + string? registrationKey = GetAgentRegistrationKey(request); - if (string.IsNullOrEmpty(agentName)) + if (string.IsNullOrEmpty(registrationKey)) { - return ValueTask.FromResult(new ResponseError + return new ResponseError { Code = "missing_required_parameter", Message = "No 'agent.name' or 'metadata[\"entity_id\"]' specified in the request." - }); + }; } - // Validate that the agent can be resolved - AIAgent? agent = this._serviceProvider.GetKeyedService(agentName); - if (agent is null) + AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) { - if (this._logger.IsEnabled(LogLevel.Warning)) + IServiceProvider services = scope.ServiceProvider; + + // Validate that the agent can be resolved + AIAgent? agent = services.GetKeyedService(registrationKey); + if (agent is null) { - this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", agentName); + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", registrationKey); + } + + return new ResponseError + { + Code = "agent_not_found", + Message = $""" + Agent '{registrationKey}' not found. + Ensure the agent is registered with '{registrationKey}' name in the dependency injection container. + We recommend using 'builder.AddAIAgent()' for simplicity. + """ + }; } - return ValueTask.FromResult(new ResponseError + // An approval response can be validated only against the pending request stored by the server. +#pragma warning disable MAAI001 + AgentSessionStore? sessionStore = services.GetKeyedService(registrationKey); +#pragma warning restore MAAI001 + ResponseError? sessionError = AgentResponseExecution.ValidateSessionRequirements(request, sessionStore is not null); + if (sessionError is not null) { - Code = "agent_not_found", - Message = $""" - Agent '{agentName}' not found. - Ensure the agent is registered with '{agentName}' name in the dependency injection container. - We recommend using 'builder.AddAIAgent()' for simplicity. - """ - }); - } + return sessionError; + } - // Surface unsupported request settings as a clean request error rather than an unhandled - // exception during execution. - try - { - _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); - } - catch (NotSupportedException ex) - { - return ValueTask.FromResult(new ResponseError + // Surface unsupported request settings as a clean request error rather than an unhandled + // exception during execution. + try { - Code = "unsupported_parameter", - Message = ex.Message - }); - } + _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); + } + catch (NotSupportedException ex) + { + return new ResponseError + { + Code = "unsupported_parameter", + Message = ex.Message + }; + } - return ValueTask.FromResult(null); + AIAgent executionAgent = sessionStore is null + ? agent + : new AIHostAgent(agent, sessionStore, sessionStorageIdentity: registrationKey); + return await AgentResponseExecution.ValidatePendingApprovalResponsesAsync( + executionAgent, request, cancellationToken).ConfigureAwait(false); + } } /// @@ -104,28 +124,28 @@ public async IAsyncEnumerable ExecuteAsync( IReadOnlyList? conversationHistory = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - string agentName = GetAgentName(request)!; - AIAgent agent = this._serviceProvider.GetRequiredKeyedService(agentName); - - // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any) - // request settings are mapped onto the agent run. By default no request setting is mapped. - AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); - var messages = new List(); - - if (conversationHistory is not null) - { - messages.AddRange(conversationHistory); - } - - foreach (var inputMessage in request.Input.GetInputMessages()) + // Resolve the agent selected by this request together with its optional persisted session store. + string registrationKey = GetAgentRegistrationKey(request)!; + // The scope surrounds the full enumeration so background execution retains scoped + // agents and stores through session persistence after the HTTP request has returned. + AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) { - messages.Add(inputMessage.ToChatMessage()); - } - - await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken) - .ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false)) - { - yield return streamingEvent; + IServiceProvider services = scope.ServiceProvider; + AIAgent agent = services.GetRequiredKeyedService(registrationKey); +#pragma warning disable MAAI001 + AgentSessionStore? sessionStore = services.GetKeyedService(registrationKey); +#pragma warning restore MAAI001 + AIAgent executionAgent = sessionStore is null + ? agent + : new AIHostAgent(agent, sessionStore, sessionStorageIdentity: registrationKey); + + // Once selected, hosted agents use the same execution behavior as fixed-agent endpoints. + await foreach (StreamingResponseEvent streamingEvent in AgentResponseExecution.ExecuteAsync( + executionAgent, this._mapOptions, context, request, conversationHistory, cancellationToken).ConfigureAwait(false)) + { + yield return streamingEvent; + } } } @@ -134,7 +154,7 @@ public async IAsyncEnumerable ExecuteAsync( /// /// The create response request. /// The agent name. - private static string? GetAgentName(CreateResponse request) + private static string? GetAgentRegistrationKey(CreateResponse request) { string? agentName = request.Agent?.Name; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs index 9d351e793d1..365851508d0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -34,6 +34,7 @@ private sealed class ResponseState public Response? Response { get; set; } public CreateResponse? Request { get; set; } public string? ConversationStorageId { get; set; } + public string? IsolationKey { get; set; } public List StreamingUpdates { get; } = []; public Task? CompletionTask { get; set; } public CancellationTokenSource? CancellationTokenSource { get; set; } @@ -206,10 +207,12 @@ public async Task CreateResponseAsync( var idGenerator = new IdGenerator(responseId: null, conversationId: request.Conversation?.Id); var responseId = idGenerator.ResponseId; - var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + // Resolve ambient caller identity before a background response detaches from the HTTP request. + string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey); var conversationStorageId = request.Conversation?.Id is { } conversationId - ? await this.GetStorageIdAsync(conversationId, cancellationToken).ConfigureAwait(false) + ? IsolationKeyResolver.ScopeId(conversationId, isolationKey) : null; var ct = request.Background switch @@ -218,7 +221,8 @@ public async Task CreateResponseAsync( _ => cancellationToken, }; - var state = this.InitializeResponse(responseId, responseStorageId, conversationStorageId, request); + var state = this.InitializeResponse( + responseId, responseStorageId, conversationStorageId, isolationKey, request); state.CompletionTask = this.ExecuteResponseAsync(responseId, state, ct); // For background responses, start execution and return immediately @@ -244,14 +248,17 @@ public async IAsyncEnumerable CreateResponseStreamingAsy var idGenerator = new IdGenerator(responseId: null, conversationId: request.Conversation?.Id); var responseId = idGenerator.ResponseId; - var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + // Streaming execution is also detached from the request cancellation token, so capture identity now. + string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey); var conversationStorageId = request.Conversation?.Id is { } conversationId - ? await this.GetStorageIdAsync(conversationId, cancellationToken).ConfigureAwait(false) + ? IsolationKeyResolver.ScopeId(conversationId, isolationKey) : null; // Start execution - var state = this.InitializeResponse(responseId, responseStorageId, conversationStorageId, request); + var state = this.InitializeResponse( + responseId, responseStorageId, conversationStorageId, isolationKey, request); state.CompletionTask = this.ExecuteResponseAsync(responseId, state, CancellationToken.None); // Stream updates as they become available @@ -408,7 +415,17 @@ private ValueTask GetStorageIdAsync(string id, CancellationToken cancell return this._isolationKeyResolver.ScopeIdAsync(id, cancellationToken); } - private ResponseState InitializeResponse(string responseId, string responseStorageId, string? conversationStorageId, CreateResponse request) + private ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken) => + this._isolationKeyResolver is null + ? new ValueTask((string?)null) + : this._isolationKeyResolver.GetKeyAsync(cancellationToken); + + private ResponseState InitializeResponse( + string responseId, + string responseStorageId, + string? conversationStorageId, + string? isolationKey, + CreateResponse request) { var metadata = request.Metadata ?? []; @@ -458,6 +475,7 @@ private ResponseState InitializeResponse(string responseId, string responseStora Response = response, Request = request, ConversationStorageId = conversationStorageId, + IsolationKey = isolationKey, CancellationTokenSource = new CancellationTokenSource() }; @@ -484,7 +502,9 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state, try { // Create agent invocation context - var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id)); + var context = new AgentInvocationContext( + new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id), + isolationKey: state.IsolationKey); // Load conversation history if a conversation ID is provided IReadOnlyList? conversationHistory = null; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs index 289bafbc437..17a36c1f7b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs @@ -266,6 +266,7 @@ internal enum FunctionToolCallOutputItemResourceStatus [JsonDerivedType(typeof(ItemContentOutputText), "output_text")] [JsonDerivedType(typeof(ItemContentOutputAudio), "output_audio")] [JsonDerivedType(typeof(ItemContentRefusal), "refusal")] +[JsonDerivedType(typeof(ItemContentFunctionApprovalResponse), "function_approval_response")] internal abstract class ItemContent { /// @@ -282,6 +283,48 @@ internal abstract class ItemContent public object? RawRepresentation { get; set; } } +/// +/// A response to a function approval request. +/// +internal sealed class ItemContentFunctionApprovalResponse : ItemContent +{ + /// + [JsonIgnore] + public override string Type => "function_approval_response"; + + /// + /// The unique identifier of the approval request. + /// + [JsonPropertyName("request_id")] + public required string RequestId { get; init; } + + /// + /// Whether the function call was approved. + /// + [JsonPropertyName("approved")] + public required bool Approved { get; init; } + + /// + /// The function call associated with the approval request. + /// + [JsonPropertyName("function_call")] + public required FunctionCallInfo FunctionCall + { + get; + init + { + // DevUI defines function_call.arguments as an object. Reject other JSON shapes while + // binding the request so malformed approval responses become HTTP 400 rather than 500. + if (value is null || value.Arguments.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The 'function_call.arguments' property must be a JSON object."); + } + + field = value; + } + } +} + /// /// Text input content. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs index f68fa12e4d1..16108486d13 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs @@ -41,8 +41,10 @@ public override IEnumerable ProcessContent(AIContent con { Id = functionCall.CallId, Name = functionCall.Name, + // DevUI requires an arguments object. Represent a no-argument call as an empty object + // so its response can pass the same strict wire validation as calls with arguments. Arguments = JsonSerializer.SerializeToElement( - functionCall.Arguments, + functionCall.Arguments ?? new Dictionary(), jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) } }; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs index d51371f24f3..67fc3d03a4f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.Agents.AI.Hosting.OpenAI.Conversations; using Microsoft.Agents.AI.Hosting.OpenAI.Responses; using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Extensions.DependencyInjection; @@ -42,6 +43,12 @@ public static IServiceCollection AddOpenAIChatCompletions(this IServiceCollectio /// . Hosts serving multiple callers should register a provider, /// require authentication on the mapped endpoints, and use a stable claim that uniquely identifies the caller. /// Without a provider, all callers share the same in-memory namespace. + /// + /// Agents that expose tools must also + /// configure an . The store preserves the server-recorded approval request + /// across HTTP requests so an incoming decision can be matched to the exact function call shown to the user. + /// A function_approval_response request is rejected when no session store is configured. + /// /// public static IServiceCollection AddOpenAIResponses(this IServiceCollection services) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs index a0396feeccf..30cf4b102fe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -27,21 +27,38 @@ namespace Microsoft.Agents.AI.Hosting; public class AIHostAgent : DelegatingAIAgent { private readonly AgentSessionStore _sessionStore; + private string? _capturedIsolationKey; /// /// Initializes a new instance of the class. /// /// The underlying agent implementation to wrap. /// The session store to use for persisting conversation state. + /// + /// The optional logical hosting identity used only to partition persisted sessions across agent instances. + /// It does not replace or . + /// /// /// or is . /// - public AIHostAgent(AIAgent innerAgent, AgentSessionStore sessionStore) + public AIHostAgent( + AIAgent innerAgent, + AgentSessionStore sessionStore, + string? sessionStorageIdentity = null) : base(innerAgent) { this._sessionStore = Throw.IfNull(sessionStore); + this.SessionStorageIdentity = sessionStorageIdentity is null + ? null + : Throw.IfNullOrWhitespace(sessionStorageIdentity); } + /// + /// Gets the stable logical identity used to partition session storage, or when + /// session storage follows the wrapped agent instance identity. + /// + internal string? SessionStorageIdentity { get; } + /// /// Gets an existing agent session for the specified conversation, or creates a new one if none exists. /// @@ -66,10 +83,9 @@ public ValueTask GetOrCreateSessionAsync( _ = Throw.IfNull(key); MarkFeatureUsed(); - return this._sessionStore.GetOrCreateSessionAsync( - this.InnerAgent, - key, - cancellationToken); + return this._capturedIsolationKey is null + ? this._sessionStore.GetOrCreateSessionAsync(this, key, cancellationToken) + : this.GetOrCreateSessionWithCapturedIsolationKeyAsync(key, cancellationToken); } /// @@ -100,11 +116,51 @@ public ValueTask SaveSessionAsync( _ = Throw.IfNull(session); MarkFeatureUsed(); - return this._sessionStore.SaveSessionAsync( - this.InnerAgent, - key, - session, - cancellationToken); + return this._capturedIsolationKey is null + ? this._sessionStore.SaveSessionAsync(this, key, session, cancellationToken) + : this.SaveSessionWithCapturedIsolationKeyAsync(key, session, cancellationToken); + } + + /// + /// Creates a host agent whose isolation-aware session store uses a key captured from the originating request. + /// + /// The trusted caller isolation key, or when isolation does not apply. + /// + /// A host agent bound to , or this instance when its store does not use + /// anywhere in its decorator pipeline or no key was supplied. + /// + public AIHostAgent BindIsolationKey(string? isolationKey) + { + if (isolationKey is null || + this._sessionStore.GetService() is null) + { + return this; + } + + // Keep the configured decorator pipeline intact. Session operations flow the captured key + // to the isolation layer when they reach it, even when other decorators wrap that layer. + var boundAgent = new AIHostAgent(this.InnerAgent, this._sessionStore, this.SessionStorageIdentity); + boundAgent._capturedIsolationKey = Throw.IfNullOrWhitespace(isolationKey); + return boundAgent; + } + + private async ValueTask GetOrCreateSessionWithCapturedIsolationKeyAsync( + AgentSessionStoreKey key, + CancellationToken cancellationToken) + { + using IDisposable isolationScope = + IsolationKeyScopedAgentSessionStore.UseCapturedIsolationKey(this._capturedIsolationKey!); + return await this._sessionStore.GetOrCreateSessionAsync(this, key, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask SaveSessionWithCapturedIsolationKeyAsync( + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken) + { + using IDisposable isolationScope = + IsolationKeyScopedAgentSessionStore.UseCapturedIsolationKey(this._capturedIsolationKey!); + await this._sessionStore.SaveSessionAsync(this, key, session, cancellationToken).ConfigureAwait(false); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index c6772d5bba3..89b8778a65f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -17,6 +17,7 @@ namespace Microsoft.Agents.AI.Hosting; public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore { private const string IsolationPartitionName = "isolation"; + private static readonly AsyncLocal s_capturedIsolationKey = new(); private readonly AgentIsolationKeyProvider? _keyProvider; private readonly bool _strict; @@ -55,9 +56,16 @@ public IsolationKeyScopedAgentSessionStore( /// private async ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken) { - string? key = this._keyProvider != null - ? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false) - : null; + // Background execution supplies the trusted request-time key through the async call flow. + // This takes precedence over ambient providers such as IHttpContextAccessor, whose request + // may already have ended by the time a nested isolation decorator is reached. + string? key = s_capturedIsolationKey.Value; + if (key is null) + { + key = this._keyProvider != null + ? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false) + : null; + } if (string.IsNullOrWhiteSpace(key)) { @@ -85,6 +93,12 @@ private async ValueTask GetScopedKeyAsync( return isolationKey is null ? key : key.WithPartition(IsolationPartitionName, isolationKey); } + /// + /// Overrides ambient isolation lookup for the current asynchronous operation. + /// + internal static IDisposable UseCapturedIsolationKey(string isolationKey) => + new CapturedIsolationKeyScope(Throw.IfNullOrWhitespace(isolationKey)); + /// public override async ValueTask GetSessionAsync( AIAgent agent, @@ -115,4 +129,25 @@ public override async ValueTask SaveSessionAsync( AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); await this.InnerStore.SaveSessionAsync(agent, scopedKey, session, cancellationToken).ConfigureAwait(false); } + + private sealed class CapturedIsolationKeyScope : IDisposable + { + private readonly string? _previousIsolationKey; + private bool _disposed; + + public CapturedIsolationKeyScope(string isolationKey) + { + this._previousIsolationKey = s_capturedIsolationKey.Value; + s_capturedIsolationKey.Value = isolationKey; + } + + public void Dispose() + { + if (!this._disposed) + { + s_capturedIsolationKey.Value = this._previousIsolationKey; + this._disposed = true; + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 084c9eaabce..56dc7ee3bbf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -41,7 +41,7 @@ namespace Microsoft.Agents.AI.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary<(string AgentId, AgentSessionStoreKey Key), JsonElement> _sessions = new(); + private readonly ConcurrentDictionary<(string AgentIdentity, AgentSessionStoreKey Key), JsonElement> _sessions = new(); /// public override async ValueTask SaveSessionAsync( @@ -54,7 +54,7 @@ public override async ValueTask SaveSessionAsync( _ = Throw.IfNull(key); _ = Throw.IfNull(session); - var storageKey = (agent.Id, key); + var storageKey = (GetStorageIdentity(agent), key); this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -67,8 +67,18 @@ public override async ValueTask SaveSessionAsync( _ = Throw.IfNull(agent); _ = Throw.IfNull(key); - return this._sessions.TryGetValue((agent.Id, key), out JsonElement existingSession) + return this._sessions.TryGetValue((GetStorageIdentity(agent), key), out JsonElement existingSession) ? await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false) : null; } + + private static string GetStorageIdentity(AIAgent agent) + { + // Hosted execution can supply a logical storage identity that survives transient agent instances. + // Direct agents retain process-local instance isolation through their generated ID. + string? sessionStorageIdentity = agent.GetService()?.SessionStorageIdentity; + return sessionStorageIdentity is null + ? $"id:{agent.Id}" + : $"host:{sessionStorageIdentity}"; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs index 683c4c0cb4d..58d00dfd6e2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs @@ -3,11 +3,20 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; +using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.OpenAI.Tests; +using Microsoft.Agents.AI.Workflows; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; @@ -17,9 +26,19 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; /// public sealed class FunctionApprovalTests : ConformanceTestBase { + private static readonly bool[] s_booleanValues = [false, true]; + // Streaming request JSON for OpenAI Responses API private const string StreamingRequestJson = @"{""model"":""gpt-4o-mini"",""input"":""test"",""stream"":true}"; + public static IEnumerable ApprovalContinuationScenarios => + from resolveAgent in s_booleanValues + from useConversation in s_booleanValues + from approved in s_booleanValues + from stream in s_booleanValues + from useWorkflow in s_booleanValues + select new object[] { resolveAgent, useConversation, approved, stream, useWorkflow }; + #region ToolApprovalRequestContent Tests [Fact] @@ -106,6 +125,35 @@ public async Task FunctionApprovalRequest_WithComplexArguments_GeneratesCorrectE Assert.True(argumentsElement.GetProperty("options").GetProperty("decimal").GetBoolean()); } + [Fact] + public async Task FunctionApprovalRequest_WithoutArguments_EmitsEmptyArgumentsObjectAsync() + { + // Arrange + const string AgentName = "approval-request-no-args-agent"; + ToolApprovalRequestContent approvalRequest = new( + "request-1", + new FunctionCallContent("call-1", "get_time")); + HttpClient client = await this.CreateTestServerAsync( + AgentName, + "You are a test agent.", + string.Empty, + _ => [approvalRequest]); + + // Act + using HttpResponseMessage response = await this.SendResponsesRequestAsync( + client, + AgentName, + StreamingRequestJson); + List events = ParseSseEvents(await response.Content.ReadAsStringAsync()); + + // Assert + JsonElement approvalEvent = Assert.Single(events, + item => item.GetProperty("type").GetString() == "response.function_approval.requested"); + JsonElement arguments = approvalEvent.GetProperty("function_call").GetProperty("arguments"); + Assert.Equal(JsonValueKind.Object, arguments.ValueKind); + Assert.Empty(arguments.EnumerateObject()); + } + [Fact] public async Task FunctionApprovalRequest_EmitsCorrectEventSequence_SuccessAsync() { @@ -175,6 +223,414 @@ public async Task FunctionApprovalRequest_SequenceNumbersAreCorrect_SuccessAsync #region ToolApprovalResponseContent Tests + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionApprovalResponse_WithoutSessionStore_ReturnsBadRequestAsync(bool resolveAgent) + { + // Arrange + const string AgentName = "approval-response-input-agent"; + AIAgent agent = new ChatClientAgent(new TestHelpers.SimpleMockChatClient(), name: AgentName); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.AddAIAgent(AgentName, (_, _) => agent); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + if (resolveAgent) + { + app.MapOpenAIResponses(); + } + else + { + app.MapOpenAIResponses(agent); + } + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + var responsesUri = new Uri(resolveAgent ? "/v1/responses" : $"/{AgentName}/v1/responses", UriKind.Relative); + string requestJson = $$""" + { + "agent": { "name": "{{AgentName}}" }, + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "function_approval_response", + "request_id": "request-1", + "approved": true, + "function_call": { + "id": "call-1", + "name": "get_weather", + "arguments": { "location": "Seattle" } + } + }] + }] + } + """; + using StringContent content = new(requestJson, Encoding.UTF8, "application/json"); + + // Act + using HttpResponseMessage response = await client.PostAsync(responsesUri, content); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains( + "Approval-required function calling is not supported because no AgentSessionStore is configured.", + responseBody, + StringComparison.Ordinal); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task FunctionApprovalResponse_UnknownOrCrossSessionRequestId_ReturnsBadRequestBeforeExecutionAsync( + bool resolveAgent, + bool useCrossSessionRequestId) + { + // Arrange + const string AgentName = "unknown-approval-response-agent"; + const string FunctionName = "get_weather"; + int functionInvocations = 0; + AIFunction function = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( + () => + { + Interlocked.Increment(ref functionInvocations); + return "Sunny"; + }, + FunctionName)); + int modelCalls = 0; + Mock chatClient = new(); + chatClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => + { + int call = Interlocked.Increment(ref modelCalls); + return new ChatResponse([ + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent($"call-{call}", FunctionName) + ]) + ]).ToChatResponseUpdates().ToAsyncEnumerable(); + }); + AIAgent agent = new ChatClientAgent(chatClient.Object, name: AgentName, tools: [function]); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.AddAIAgent(AgentName, (_, _) => agent) + .WithInMemorySessionStore(withIsolation: false); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + if (resolveAgent) + { + app.MapOpenAIResponses(); + } + else + { + app.MapOpenAIResponses(agent); + } + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + var responsesUri = new Uri(resolveAgent ? "/v1/responses" : $"/{AgentName}/v1/responses", UriKind.Relative); + + using StringContent initialContent = new( + $$"""{"agent":{"name":"{{AgentName}}"},"input":"What is the weather?","stream":true}""", + Encoding.UTF8, + "application/json"); + using HttpResponseMessage initialResponse = await client.PostAsync(responsesUri, initialContent); + initialResponse.EnsureSuccessStatusCode(); + List initialEvents = ParseSseEvents(await initialResponse.Content.ReadAsStringAsync()); + JsonElement approvalEvent = Assert.Single(initialEvents, + item => item.GetProperty("type").GetString() == "response.function_approval.requested"); + string responseId = initialEvents.Last().GetProperty("response").GetProperty("id").GetString()!; + if (useCrossSessionRequestId) + { + using StringContent otherInitialContent = new( + $$"""{"agent":{"name":"{{AgentName}}"},"input":"What is the weather elsewhere?","stream":true}""", + Encoding.UTF8, + "application/json"); + using HttpResponseMessage otherInitialResponse = await client.PostAsync(responsesUri, otherInitialContent); + otherInitialResponse.EnsureSuccessStatusCode(); + List otherInitialEvents = ParseSseEvents(await otherInitialResponse.Content.ReadAsStringAsync()); + _ = Assert.Single(otherInitialEvents, + item => item.GetProperty("type").GetString() == "response.function_approval.requested"); + responseId = otherInitialEvents.Last().GetProperty("response").GetProperty("id").GetString()!; + } + + string requestId = useCrossSessionRequestId + ? approvalEvent.GetProperty("request_id").GetRawText() + : JsonSerializer.Serialize("unknown-request"); + string approvalJson = $$""" + { + "agent": { "name": "{{AgentName}}" }, + "previous_response_id": {{JsonSerializer.Serialize(responseId)}}, + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "function_approval_response", + "request_id": {{requestId}}, + "approved": true, + "function_call": {{approvalEvent.GetProperty("function_call").GetRawText()}} + }] + }] + } + """; + using StringContent approvalContent = new(approvalJson, Encoding.UTF8, "application/json"); + + // Act + using HttpResponseMessage response = await client.PostAsync(responsesUri, approvalContent); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("does not match a pending approval request", responseBody, StringComparison.Ordinal); + Assert.Equal(0, functionInvocations); + Assert.Equal(useCrossSessionRequestId ? 2 : 1, modelCalls); + } + + [Theory] + [InlineData("missing-approved")] + [InlineData("array-arguments")] + [InlineData("string-arguments")] + [InlineData("number-arguments")] + [InlineData("boolean-arguments")] + [InlineData("null-arguments")] + public async Task FunctionApprovalResponse_InvalidPayload_ReturnsBadRequestBeforeExecutionAsync(string invalidField) + { + // Arrange + const string AgentName = "missing-approved-response-agent"; + const string FunctionName = "get_weather"; + int functionInvocations = 0; + AIFunction function = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( + () => + { + Interlocked.Increment(ref functionInvocations); + return "Sunny"; + }, + FunctionName)); + int modelCalls = 0; + Mock chatClient = new(); + chatClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => + { + AIContent responseContent = Interlocked.Increment(ref modelCalls) == 1 + ? new FunctionCallContent("call-1", FunctionName) + : new TextContent("Decision processed"); + return new ChatResponse([ + new ChatMessage(ChatRole.Assistant, [responseContent]) + ]).ToChatResponseUpdates().ToAsyncEnumerable(); + }); + AIAgent agent = new ChatClientAgent(chatClient.Object, name: AgentName, tools: [function]); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.AddAIAgent(AgentName, (_, _) => agent) + .WithInMemorySessionStore(withIsolation: false); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses(agent); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + var responsesUri = new Uri($"/{AgentName}/v1/responses", UriKind.Relative); + + using StringContent initialContent = new( + """{"input":"What is the weather?","stream":true}""", + Encoding.UTF8, + "application/json"); + using HttpResponseMessage initialResponse = await client.PostAsync(responsesUri, initialContent); + initialResponse.EnsureSuccessStatusCode(); + List initialEvents = ParseSseEvents(await initialResponse.Content.ReadAsStringAsync()); + JsonElement approvalEvent = Assert.Single(initialEvents, + item => item.GetProperty("type").GetString() == "response.function_approval.requested"); + string responseId = initialEvents.Last().GetProperty("response").GetProperty("id").GetString()!; + string approvedProperty = invalidField == "missing-approved" + ? string.Empty + : "\"approved\": true,"; + string argumentsJson = invalidField switch + { + "array-arguments" => "[]", + "string-arguments" => "\"{}\"", + "number-arguments" => "42", + "boolean-arguments" => "true", + "null-arguments" => "null", + _ => "{}" + }; + string approvalJson = $$""" + { + "previous_response_id": {{JsonSerializer.Serialize(responseId)}}, + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "function_approval_response", + "request_id": {{approvalEvent.GetProperty("request_id").GetRawText()}}, + {{approvedProperty}} + "function_call": { + "id": "call-1", + "name": "{{FunctionName}}", + "arguments": {{argumentsJson}} + } + }] + }] + } + """; + using StringContent approvalContent = new(approvalJson, Encoding.UTF8, "application/json"); + + // Act + using HttpResponseMessage response = await client.PostAsync(responsesUri, approvalContent); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(0, functionInvocations); + Assert.Equal(1, modelCalls); + } + + [Theory] + [MemberData(nameof(ApprovalContinuationScenarios))] + public async Task FunctionApprovalResponse_PendingExecution_ResumesAcrossResponsesAsync( + bool resolveAgent, + bool useConversation, + bool approved, + bool stream, + bool useWorkflow) + { + // Arrange + const string AgentName = "approval-workflow-agent"; + const string FunctionName = "get_weather"; + int functionInvocations = 0; + AIFunction function = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( + (string location) => + { + Interlocked.Increment(ref functionInvocations); + return $"Sunny in {location}"; + }, + FunctionName)); + int modelCalls = 0; + List? secondModelMessages = null; + Mock chatClient = new(); + chatClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable messages, ChatOptions? _, CancellationToken _) => + { + int call = Interlocked.Increment(ref modelCalls); + if (call == 2) + { + secondModelMessages = messages.ToList(); + } + + AIContent content = call == 1 + ? new FunctionCallContent("call-1", FunctionName, new Dictionary { ["location"] = "Seattle" }) + : new TextContent("Decision processed"); + return new ChatResponse([new ChatMessage(ChatRole.Assistant, [content])]) + .ToChatResponseUpdates() + .ToAsyncEnumerable(); + }); + AIAgent innerAgent = new ChatClientAgent( + chatClient.Object, + name: useWorkflow ? "inner-agent" : AgentName, + tools: [function]); + AIAgent agent = useWorkflow + ? new WorkflowBuilder(innerAgent.BindAsExecutor(new AIAgentHostOptions { EmitAgentUpdateEvents = true })) + .Build() + .AsAIAgent(name: AgentName) + : innerAgent; + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.AddAIAgent(AgentName, (_, _) => agent) + .WithInMemorySessionStore(withIsolation: false); + builder.AddOpenAIResponses(); + builder.AddOpenAIConversations(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIConversations(); + if (resolveAgent) + { + app.MapOpenAIResponses(); + } + else + { + app.MapOpenAIResponses(agent); + } + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + var responsesUri = new Uri(resolveAgent ? "/v1/responses" : $"/{AgentName}/v1/responses", UriKind.Relative); + string? conversationId = null; + if (useConversation) + { + using StringContent createConversationContent = new("{}", Encoding.UTF8, "application/json"); + using HttpResponseMessage conversationResponse = await client.PostAsync( + new Uri("/v1/conversations", UriKind.Relative), + createConversationContent); + conversationResponse.EnsureSuccessStatusCode(); + using JsonDocument conversationDocument = JsonDocument.Parse(await conversationResponse.Content.ReadAsStringAsync()); + conversationId = conversationDocument.RootElement.GetProperty("id").GetString(); + } + + string conversationProperty = conversationId is null + ? string.Empty + : $""","conversation":{JsonSerializer.Serialize(conversationId)}"""; + using StringContent initialContent = new( + $$"""{"agent":{"name":"{{AgentName}}"},"input":"What is the weather in Seattle?","stream":true{{conversationProperty}}}""", + Encoding.UTF8, + "application/json"); + using HttpResponseMessage initialResponse = await client.PostAsync(responsesUri, initialContent); + initialResponse.EnsureSuccessStatusCode(); + List initialEvents = ParseSseEvents(await initialResponse.Content.ReadAsStringAsync()); + JsonElement approvalEvent = Assert.Single(initialEvents, + item => item.GetProperty("type").GetString() == "response.function_approval.requested"); + string responseId = initialEvents.Last().GetProperty("response").GetProperty("id").GetString()!; + string continuationProperty = conversationId is null + ? $"\"previous_response_id\":{JsonSerializer.Serialize(responseId)}" + : $"\"conversation\":{JsonSerializer.Serialize(conversationId)}"; + string approvalJson = $$""" + { + "agent": { "name": "{{AgentName}}" }, + {{continuationProperty}}, + "stream": {{(stream ? "true" : "false")}}, + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "function_approval_response", + "request_id": {{approvalEvent.GetProperty("request_id").GetRawText()}}, + "approved": {{(approved ? "true" : "false")}}, + "function_call": {{approvalEvent.GetProperty("function_call").GetRawText()}} + }] + }] + } + """; + using StringContent approvalContent = new(approvalJson, Encoding.UTF8, "application/json"); + + // Act + using HttpResponseMessage response = await client.PostAsync(responsesUri, approvalContent); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.True(response.IsSuccessStatusCode, responseBody); + Assert.Equal(approved ? 1 : 0, functionInvocations); + Assert.Equal(2, modelCalls); + Assert.Equal( + 1, + secondModelMessages!.Count(message => + message.Text?.Contains("What is the weather in Seattle?", StringComparison.Ordinal) == true)); + Assert.Contains("Decision processed", responseBody, StringComparison.Ordinal); + } + [Fact] public async Task FunctionApprovalResponse_Approved_GeneratesCorrectEvent_SuccessAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIsolationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIsolationTests.cs index 4a2559e1066..48f331ec0f1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIsolationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIsolationTests.cs @@ -233,6 +233,83 @@ public async Task BackgroundResponse_UsesCapturedConversationStorageIdAsync() Assert.Contains("Background message", items.RootElement.GetRawText(), StringComparison.Ordinal); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BackgroundResponse_WithSessionStore_PreservesCallerIsolationAsync( + bool mapRegisteredResponseService) + { + // Arrange + HttpClient client = await this.CreateTestServerAsync( + mapRegisteredResponseService, + withSessionStore: true); + string requestBody = JsonSerializer.Serialize(new + { + metadata = new { entity_id = AgentName }, + input = "Background session message", + background = true, + stream = false + }); + + // Act + using HttpResponseMessage createResponse = await SendAsync( + client, + HttpMethod.Post, + Alice, + mapRegisteredResponseService ? "/v1/responses" : $"/{AgentName}/v1/responses", + requestBody); + createResponse.EnsureSuccessStatusCode(); + + using JsonDocument created = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync()); + string responseId = created.RootElement.GetProperty("id").GetString()!; + + // Assert + await WaitForResponseCompletionAsync( + client, + Alice, + responseId, + mapRegisteredResponseService ? "/v1/responses" : $"/{AgentName}/v1/responses"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BackgroundResponse_WithNestedIsolationStore_PreservesCallerIsolationAsync( + bool mapRegisteredResponseService) + { + // Arrange + HttpClient client = await this.CreateTestServerAsync( + mapRegisteredResponseService, + withSessionStore: true, + nestIsolationStore: true); + string requestBody = JsonSerializer.Serialize(new + { + metadata = new { entity_id = AgentName }, + input = "Background session message", + background = true, + stream = false + }); + + // Act + using HttpResponseMessage createResponse = await SendAsync( + client, + HttpMethod.Post, + Alice, + mapRegisteredResponseService ? "/v1/responses" : $"/{AgentName}/v1/responses", + requestBody); + createResponse.EnsureSuccessStatusCode(); + + using JsonDocument created = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync()); + string responseId = created.RootElement.GetProperty("id").GetString()!; + + // Assert + await WaitForResponseCompletionAsync( + client, + Alice, + responseId, + mapRegisteredResponseService ? "/v1/responses" : $"/{AgentName}/v1/responses"); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -337,14 +414,15 @@ private static Task CreateResponseForConversationAsync( private static async Task WaitForResponseCompletionAsync( HttpClient client, string principal, - string responseId) + string responseId, + string responsesPath = $"/{AgentName}/v1/responses") { for (int attempt = 0; attempt < 100; attempt++) { using JsonDocument response = await GetJsonAsync( client, principal, - $"/{AgentName}/v1/responses/{responseId}"); + $"{responsesPath}/{responseId}"); string? status = response.RootElement.GetProperty("status").GetString(); if (status == "completed") @@ -415,7 +493,9 @@ private static async Task SendAsync( private async Task CreateTestServerAsync( bool mapRegisteredResponseService = false, - bool withIsolation = true) + bool withIsolation = true, + bool withSessionStore = false, + bool nestIsolationStore = false) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -423,10 +503,25 @@ private async Task CreateTestServerAsync( builder.Services.AddKeyedSingleton( "chat-client", new TestHelpers.SimpleMockChatClient("The capital of France is Paris.")); - builder.AddAIAgent( + var agentBuilder = builder.AddAIAgent( AgentName, "You are a helpful assistant.", chatClientServiceKey: "chat-client"); + if (withSessionStore) + { + if (nestIsolationStore) + { + agentBuilder.WithSessionStore( + (services, _) => new TestDelegatingAgentSessionStore( + new IsolationKeyScopedAgentSessionStore( + new InMemoryAgentSessionStore(), + services.GetRequiredService()))); + } + else + { + agentBuilder.WithInMemorySessionStore(); + } + } if (withIsolation) { @@ -485,4 +580,7 @@ public HeaderAgentIsolationKeyProvider(IHttpContextAccessor httpContextAccessor) return new ValueTask(string.IsNullOrEmpty(key) ? null : key); } } + + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) + : DelegatingAgentSessionStore(innerStore); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs new file mode 100644 index 00000000000..d68adfe50af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs @@ -0,0 +1,601 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Regression tests for session lifetime and ordering across Responses API requests. +/// +public sealed class ResponseSessionRegressionTests +{ + private const string AgentName = "session-regression-agent"; + private const string ToolName = "get_weather"; + + [Fact] + public async Task ApprovalContinuation_TransientAgent_UsesStableRegistrationIdentityAsync() + { + // Arrange + int modelCalls = 0; + int toolCalls = 0; + AIFunction function = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( + () => + { + Interlocked.Increment(ref toolCalls); + return "Sunny"; + }, + ToolName)); + Mock chatClient = CreateApprovalChatClient(() => Interlocked.Increment(ref modelCalls)); + + WebApplicationBuilder builder = CreateBuilder(); + builder.AddAIAgent( + AgentName, + (_, name) => new ChatClientAgent(chatClient.Object, name: name, tools: [function]), + ServiceLifetime.Transient) + .WithInMemorySessionStore(withIsolation: false); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses(); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + (string responseId, JsonElement approvalEvent) = await CreatePendingApprovalAsync( + client, + "/v1/responses", + includeAgentName: true); + using StringContent approvalContent = JsonContent(CreateApprovalResponseJson( + responseId, + approvalEvent, + includeAgentName: true)); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri("/v1/responses", UriKind.Relative), + approvalContent); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.True(response.IsSuccessStatusCode, responseBody); + Assert.Equal(1, toolCalls); + Assert.Equal(2, modelCalls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ScopedSessionStore_IsResolvedFromExecutionScopeAsync(bool resolveAgent) + { + // Arrange + WebApplicationBuilder builder = CreateBuilder(validateScopes: true); + AIAgent agent = new ChatClientAgent(new TestHelpers.SimpleMockChatClient(), name: AgentName); + builder.AddAIAgent(AgentName, (_, _) => agent) + .WithSessionStore( + (_, _) => new InMemoryAgentSessionStore(), + ServiceLifetime.Scoped, + withIsolation: false); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + if (resolveAgent) + { + app.MapOpenAIResponses(); + } + else + { + app.MapOpenAIResponses(agent); + } + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + string path = resolveAgent ? "/v1/responses" : $"/{AgentName}/v1/responses"; + using StringContent content = JsonContent($$""" + { + "agent": { "name": "{{AgentName}}" }, + "input": "hello" + } + """); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri(path, UriKind.Relative), + content); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ScopedAgent_IsResolvedFromExecutionScopeAsync(bool resolveAgent) + { + // Arrange + WebApplicationBuilder builder = CreateBuilder(validateScopes: true); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent( + AgentName, + (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient(), name: name), + ServiceLifetime.Scoped); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + if (resolveAgent) + { + app.MapOpenAIResponses(); + } + else + { + app.MapOpenAIResponses(agentBuilder); + } + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + string path = resolveAgent ? "/v1/responses" : $"/{AgentName}/v1/responses"; + using StringContent content = JsonContent($$""" + { + "agent": { "name": "{{AgentName}}" }, + "input": "hello" + } + """); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri(path, UriKind.Relative), + content); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BackgroundExecution_KeepsScopedSessionStoreAliveThroughPersistenceAsync(bool resolveAgent) + { + // Arrange + var agent = new PausingApprovalAgent(); + var storeTracker = new ScopedSessionStoreTracker(); + WebApplicationBuilder builder = CreateBuilder(validateScopes: true); + builder.AddAIAgent(AgentName, (_, _) => agent) + .WithSessionStore( + (_, _) => storeTracker.CreateStore(), + ServiceLifetime.Scoped, + withIsolation: false); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + if (resolveAgent) + { + app.MapOpenAIResponses(); + } + else + { + app.MapOpenAIResponses(agent); + } + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + string path = resolveAgent ? "/v1/responses" : $"/{AgentName}/v1/responses"; + string requestJson = resolveAgent + ? $$""" + { + "agent": { "name": "{{AgentName}}" }, + "input": "hello", + "background": true + } + """ + : """ + { + "input": "hello", + "background": true + } + """; + using StringContent content = JsonContent(requestJson); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri(path, UriKind.Relative), + content); + string responseBody = await response.Content.ReadAsStringAsync(); + response.EnsureSuccessStatusCode(); + using JsonDocument responseDocument = JsonDocument.Parse(responseBody); + string responseId = Assert.IsType( + responseDocument.RootElement.GetProperty("id").GetString()); + TrackingSessionStore executionStore = + await storeTracker.ExecutionStoreCreated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + try + { + Assert.False(executionStore.IsDisposed); + agent.ReleaseInitialRun(); + await WaitForResponseCompletionAsync(client, path, responseId); + await executionStore.Disposed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(1, executionStore.SaveCount); + } + finally + { + agent.ReleaseInitialRun(); + } + } + + [Fact] + public async Task StreamingApproval_RequiresTerminalEventBeforeContinuationAsync() + { + // Arrange + var agent = new PausingApprovalAgent(); + WebApplicationBuilder builder = CreateBuilder(); + builder.AddAIAgent(AgentName, (_, _) => agent) + .WithInMemorySessionStore(withIsolation: false); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses(agent); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + var initialRequest = new HttpRequestMessage(HttpMethod.Post, $"/{AgentName}/v1/responses") + { + Content = JsonContent("""{"input":"hello","stream":true}""") + }; + using HttpResponseMessage initialResponse = await client.SendAsync( + initialRequest, + HttpCompletionOption.ResponseHeadersRead); + initialResponse.EnsureSuccessStatusCode(); + await using Stream initialStream = await initialResponse.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(initialStream); + (string responseId, JsonElement approvalEvent) = await ReadApprovalEventAsync(reader); + string approvalJson = CreateApprovalResponseJson( + responseId, + approvalEvent, + includeAgentName: false); + + // Act + try + { + using HttpResponseMessage prematureResponse = await PostJsonAsync( + client, + $"/{AgentName}/v1/responses", + approvalJson).WaitAsync(TimeSpan.FromSeconds(5)); + string prematureBody = await prematureResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.BadRequest, prematureResponse.StatusCode); + Assert.Contains("does not match a pending approval request", prematureBody, StringComparison.Ordinal); + + // Act + agent.ReleaseInitialRun(); + string remainingEvents = await reader.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains("response.completed", remainingEvents, StringComparison.Ordinal); + using HttpResponseMessage approvalResponse = await PostJsonAsync( + client, + $"/{AgentName}/v1/responses", + approvalJson).WaitAsync(TimeSpan.FromSeconds(5)); + string approvalBody = await approvalResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.True(approvalResponse.IsSuccessStatusCode, approvalBody); + } + finally + { + agent.ReleaseInitialRun(); + } + } + + private static WebApplicationBuilder CreateBuilder(bool validateScopes = false) + { + var options = new WebApplicationOptions + { + EnvironmentName = validateScopes ? Environments.Development : Environments.Production + }; + WebApplicationBuilder builder = WebApplication.CreateBuilder(options); + builder.WebHost.UseTestServer(); + return builder; + } + + private static Mock CreateApprovalChatClient(Func nextCall) + { + Mock chatClient = new(); + chatClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => + { + AIContent content = nextCall() == 1 + ? new FunctionCallContent("call-1", ToolName) + : new TextContent("Decision processed"); + return new ChatResponse([ + new ChatMessage(ChatRole.Assistant, [content]) + ]).ToChatResponseUpdates().ToAsyncEnumerable(); + }); + return chatClient; + } + + private static async Task<(string ResponseId, JsonElement ApprovalEvent)> CreatePendingApprovalAsync( + HttpClient client, + string path, + bool includeAgentName) + { + string agentProperty = includeAgentName + ? $$""" "agent": { "name": "{{AgentName}}" },""" + : string.Empty; + using StringContent content = JsonContent($$""" + { + {{agentProperty}} + "input": "hello", + "stream": true + } + """); + using HttpResponseMessage response = await client.PostAsync( + new Uri(path, UriKind.Relative), + content); + response.EnsureSuccessStatusCode(); + + List events = ParseSseEvents(await response.Content.ReadAsStringAsync()); + JsonElement approvalEvent = Assert.Single(events, + item => item.GetProperty("type").GetString() == "response.function_approval.requested"); + string responseId = events.Last().GetProperty("response").GetProperty("id").GetString()!; + return (responseId, approvalEvent); + } + + private static string CreateApprovalResponseJson( + string responseId, + JsonElement approvalEvent, + bool includeAgentName) + { + string agentProperty = includeAgentName + ? $$""" "agent": { "name": "{{AgentName}}" },""" + : string.Empty; + return $$""" + { + {{agentProperty}} + "previous_response_id": {{JsonSerializer.Serialize(responseId)}}, + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "function_approval_response", + "request_id": {{approvalEvent.GetProperty("request_id").GetRawText()}}, + "approved": true, + "function_call": {{approvalEvent.GetProperty("function_call").GetRawText()}} + }] + }] + } + """; + } + + private static async Task<(string ResponseId, JsonElement ApprovalEvent)> ReadApprovalEventAsync( + StreamReader reader) + { + string? responseId = null; + while (await reader.ReadLineAsync() is { } line) + { + if (!line.StartsWith("data: ", StringComparison.Ordinal)) + { + continue; + } + + using JsonDocument document = JsonDocument.Parse(line["data: ".Length..]); + JsonElement item = document.RootElement; + string? type = item.GetProperty("type").GetString(); + if (type == "response.created") + { + responseId = item.GetProperty("response").GetProperty("id").GetString(); + } + else if (type == "response.function_approval.requested") + { + return (Assert.IsType(responseId), item.Clone()); + } + } + + throw new InvalidOperationException("The stream ended before an approval request was emitted."); + } + + private static List ParseSseEvents(string content) + { + var events = new List(); + foreach (string line in content.Split('\n')) + { + if (!line.StartsWith("data: ", StringComparison.Ordinal)) + { + continue; + } + + using JsonDocument document = JsonDocument.Parse(line["data: ".Length..]); + events.Add(document.RootElement.Clone()); + } + + return events; + } + + private static StringContent JsonContent(string json) => + new(json, Encoding.UTF8, "application/json"); + + private static async Task PostJsonAsync( + HttpClient client, + string path, + string json) + { + using StringContent content = JsonContent(json); + return await client.PostAsync(new Uri(path, UriKind.Relative), content); + } + + private static async Task WaitForResponseCompletionAsync( + HttpClient client, + string path, + string responseId) + { + for (int attempt = 0; attempt < 100; attempt++) + { + using HttpResponseMessage response = await client.GetAsync( + new Uri($"{path}/{responseId}", UriKind.Relative)); + string responseBody = await response.Content.ReadAsStringAsync(); + response.EnsureSuccessStatusCode(); + using JsonDocument document = JsonDocument.Parse(responseBody); + string? status = document.RootElement.GetProperty("status").GetString(); + if (status == "completed") + { + return; + } + + if (status is "failed" or "cancelled" or "incomplete") + { + throw new InvalidOperationException( + $"Background response entered terminal status '{status}'."); + } + + await Task.Delay(TimeSpan.FromMilliseconds(20)); + } + + throw new TimeoutException("Background response did not complete."); + } + + private sealed class PausingApprovalAgent : AIAgent + { + private readonly TaskCompletionSource _releaseInitialRun = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override string Name => AgentName; + + public void ReleaseInitialRun() => this._releaseInitialRun.TrySetResult(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + new(new TestSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(session.StateBag.Serialize()); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(new TestSession(AgentSessionStateBag.Deserialize(serializedState))); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This regression test uses streaming execution."); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (messages.SelectMany(message => message.Contents).OfType().Any()) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, "Decision processed"); + yield break; + } + + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [ + new ToolApprovalRequestContent( + "request-1", + new FunctionCallContent( + "call-1", + ToolName, + new Dictionary())) + ] + }; + + await this._releaseInitialRun.Task.WaitAsync(cancellationToken); + } + + private sealed class TestSession : AgentSession + { + public TestSession() + { + } + + public TestSession(AgentSessionStateBag stateBag) + : base(stateBag) + { + } + } + } + + private sealed class ScopedSessionStoreTracker + { + private int _executionStoreRecorded; + + public TaskCompletionSource ExecutionStoreCreated { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TrackingSessionStore CreateStore() => new(this); + + public void RecordExecutionStore(TrackingSessionStore store) + { + if (Interlocked.Exchange(ref this._executionStoreRecorded, 1) == 0) + { + this.ExecutionStoreCreated.TrySetResult(store); + } + } + } + + private sealed class TrackingSessionStore : AgentSessionStore, IDisposable + { + private readonly InMemoryAgentSessionStore _inner = new(); + private readonly ScopedSessionStoreTracker _tracker; + + public TrackingSessionStore(ScopedSessionStoreTracker tracker) + { + this._tracker = tracker; + } + + public TaskCompletionSource Disposed { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool IsDisposed { get; private set; } + + public int SaveCount { get; private set; } + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(this.IsDisposed, this); + this._tracker.RecordExecutionStore(this); + return this._inner.GetSessionAsync(agent, key, cancellationToken); + } + + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(this.IsDisposed, this); + this.SaveCount++; + return this._inner.SaveSessionAsync(agent, key, session, cancellationToken); + } + + public void Dispose() + { + this.IsDisposed = true; + this.Disposed.TrySetResult(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index 694053273fe..97e99833da2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -83,6 +83,34 @@ public async Task GetSessionAsync_DifferentUsers_AreIsolatedAsync() Assert.Null(differentUser); } + [Fact] + public async Task GetSessionAsync_SharedStorageIdentity_RestoresAcrossDifferentAgentInstancesAsync() + { + // Arrange + var store = new InMemoryAgentSessionStore(); + var firstAgent = new AIHostAgent( + new ChatClientAgent(new NotInvokedChatClient(), name: "first-instance"), + store, + sessionStorageIdentity: "registered-agent"); + var secondAgent = new AIHostAgent( + new ChatClientAgent(new NotInvokedChatClient(), name: "second-instance"), + store, + sessionStorageIdentity: "registered-agent"); + var key = new AgentSessionStoreKey("session-1"); + AgentSession session = await firstAgent.CreateSessionAsync(); + session.StateBag.SetValue("marker", "persisted"); + await firstAgent.SaveSessionAsync(key, session); + + // Act + AgentSession restored = await secondAgent.GetOrCreateSessionAsync(key); + + // Assert + Assert.NotEqual(firstAgent.Id, secondAgent.Id); + Assert.NotEqual(firstAgent.Name, secondAgent.Name); + Assert.Equal(firstAgent.SessionStorageIdentity, secondAgent.SessionStorageIdentity); + Assert.Equal("persisted", restored.StateBag.GetValue("marker")); + } + // A chat client that is never invoked: these tests only create, serialize, and deserialize sessions. private sealed class NotInvokedChatClient : IChatClient { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index e9bdccbac6f..e8fc4c8a976 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using Moq; @@ -163,6 +164,60 @@ public async Task GetSessionAsync_IsolationProviderReplacesExistingIsolationPart this._innerStoreMock.VerifyAll(); } + [Fact] + public async Task BindIsolationKey_NestedIsolationStore_UsesCapturedKeyAsync() + { + // Arrange + var expectedSession = new TestAgentSession(); + var recordingStore = new RecordingSessionStore(expectedSession); + var isolationStore = new IsolationKeyScopedAgentSessionStore( + recordingStore, + new TestAgentIsolationKeyProvider(key: null), + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + var outerStore = new TestDelegatingAgentSessionStore(isolationStore); + var hostAgent = new AIHostAgent(this._agentMock.Object, outerStore); + + // Act + AIHostAgent boundAgent = hostAgent.BindIsolationKey("captured-user"); + AgentSession session = await boundAgent.GetOrCreateSessionAsync("session-1"); + + // Assert + Assert.Same(expectedSession, session); + Assert.Equal(1, outerStore.GetSessionCount); + AgentSessionStoreKey key = Assert.Single(recordingStore.Keys); + Assert.Equal("captured-user", key.Partitions!["isolation"]); + } + + [Fact] + public async Task BindIsolationKey_ConcurrentOperations_KeepCapturedKeysSeparateAsync() + { + // Arrange + var recordingStore = new RecordingSessionStore(session: null); + var isolationStore = new IsolationKeyScopedAgentSessionStore( + recordingStore, + new TestAgentIsolationKeyProvider(key: null), + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + var outerStore = new PausingDelegatingAgentSessionStore(isolationStore); + var hostAgent = new AIHostAgent(this._agentMock.Object, outerStore); + AIHostAgent aliceAgent = hostAgent.BindIsolationKey("alice"); + AIHostAgent bobAgent = hostAgent.BindIsolationKey("bob"); + var session = new TestAgentSession(); + + // Act + await Task.WhenAll( + aliceAgent.SaveSessionAsync("alice-session", session).AsTask(), + bobAgent.SaveSessionAsync("bob-session", session).AsTask()); + + // Assert + Assert.Equal(2, outerStore.SaveSessionCount); + Assert.Contains( + recordingStore.Keys, + key => key.SessionId == "alice-session" && key.Partitions!["isolation"] == "alice"); + Assert.Contains( + recordingStore.Keys, + key => key.SessionId == "bob-session" && key.Partitions!["isolation"] == "bob"); + } + private IsolationKeyScopedAgentSessionStore CreateStore( string? isolationKey, IsolationKeyScopedAgentSessionStoreOptions? options = null) @@ -178,4 +233,68 @@ private sealed class TestAgentIsolationKeyProvider(string? key) : AgentIsolation } private sealed class TestAgentSession : AgentSession; + + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) + : DelegatingAgentSessionStore(innerStore) + { + public int GetSessionCount { get; private set; } + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + this.GetSessionCount++; + return base.GetSessionAsync(agent, key, cancellationToken); + } + } + + private sealed class PausingDelegatingAgentSessionStore(AgentSessionStore innerStore) + : DelegatingAgentSessionStore(innerStore) + { + private readonly TaskCompletionSource _bothSavesStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _saveSessionCount; + + public int SaveSessionCount => this._saveSessionCount; + + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref this._saveSessionCount) == 2) + { + this._bothSavesStarted.TrySetResult(); + } + + await this._bothSavesStarted.Task.WaitAsync(cancellationToken); + await base.SaveSessionAsync(agent, key, session, cancellationToken); + } + } + + private sealed class RecordingSessionStore(AgentSession? session) : AgentSessionStore + { + public ConcurrentBag Keys { get; } = []; + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + this.Keys.Add(key); + return new(session); + } + + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + { + this.Keys.Add(key); + return ValueTask.CompletedTask; + } + } } From bf93c539d247a9df6f66055b711a3015438c8dce Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:04:43 +0000 Subject: [PATCH 20/42] .NET: [BREAKING] Enforce approval response binding consistently (#8641) * .NET: Enforce approval response binding consistently Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * .NET: Document approval response binding scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AGUIDojoServer/ChatClientAgentFactory.cs | 12 ++ .../AGUIDojoServer/Program.cs | 21 ++- .../AGUIClientServer/AGUIServer/Program.cs | 22 ++- .../05-end-to-end/AGUIClientServer/README.md | 27 ++- .../ApprovalResponseBindingChatClient.cs | 30 +--- .../ChatClient/ChatClientAgentOptions.cs | 9 +- .../ChatClient/ChatClientBuilderExtensions.cs | 6 + .../ToolCallingTests.cs | 51 ++++-- .../ApprovalResponseBindingChatClientTests.cs | 155 ++++++++++++++++-- 9 files changed, 274 insertions(+), 59 deletions(-) diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index 9ac945da40e..32ab0ec821b 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -137,6 +137,18 @@ public static AIAgent CreatePredictiveStateUpdates(JsonSerializerOptions options { Name = "PredictiveStateUpdatesAgent", Description = "An agent that demonstrates predictive state updates using Azure OpenAI", + // write_document runs on the server without approval, while confirm_changes runs on the client. + // If the model requests both in one response, the function-invocation loop returns both calls + // unexecuted because confirm_changes is only a declaration on the server. Bypassing saves + // write_document in the server session and initially exposes only confirm_changes. + // The client executes confirm_changes and sends its result with the original call ID in + // a continuation request for the same thread. On that next request, the server executes + // the saved write_document call. Its call and matching result then reach the client in + // the continuation stream as completed history, not as a request for client-side execution. + // Program.cs registers a session store keyed by this agent's name so the deferred call + // survives across HTTP requests; without it, the call is lost when the client continues. + // Execution therefore uses the stored call, not a call reconstructed from client-supplied history. + EnableInvocableFunctionBypassing = true, ChatOptions = new ChatOptions { Instructions = """ diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs index 49100e42d84..8a0a393ebd4 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using AGUIDojoServer; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.AspNetCore.HttpLogging; using Microsoft.Extensions.Options; @@ -19,9 +21,22 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default)); builder.Services.AddAGUIServer(); -// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, -// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user -// deployments, e.g.: +// A session store is REQUIRED for mixed client/server tool continuation. When write_document and +// confirm_changes are requested together, invocable function bypassing saves the server's write_document +// call in the session and sends only confirm_changes to the client. The client's result returns in a +// separate HTTP request for the same thread, which must reload that session to execute the saved call. +// Without a session store, the deferred call is lost between requests. Client-replayed tool calls are not +// a trusted substitute for the server-side record. The registration key must match the agent's name so +// MapAGUIServer can find its store. +// In production, use a persistent session store instead of the in-memory one: InMemoryAgentSessionStore +// loses sessions on restart and keeps every session for the lifetime of the process, with no size limit, +// expiry or eviction. Thread ids are client-supplied, so multi-user hosts also need the isolation below. +// This store has no atomic consume: concurrent continuations on one thread can read the same pending call +// before either saves its updated session, potentially executing that call more than once. +builder.Services.AddKeyedSingleton("PredictiveStateUpdatesAgent", new InMemoryAgentSessionStore()); + +// WARNING: When session persistence is enabled, in a multi-user deployment you must also register an +// AgentIsolationKeyProvider to scope sessions by principal, e.g.: // builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); WebApplication app = builder.Build(); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs index b5c1f946a3c..bb8c8c484bf 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using AGUIServer; using Azure.Identity; +using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Extensions.AI; @@ -40,7 +41,26 @@ // Register the agent with the host and configure it to use an in-memory session store // so that conversation state is maintained across requests. In production, you may want to use a persistent session store. builder - .AddAIAgent(AgentName, "You are a helpful assistant.", chatClient) + .AddAIAgent(AgentName, (services, name) => chatClient.AsAIAgent(new ChatClientAgentOptions + { + Name = name, + // Client tools are declarations on the server. If the model requests client and server tools + // in the same response, the function-invocation loop returns both calls without executing them. + // Bypassing keeps the non-approval-required server calls in the server session and initially + // exposes only the client calls. The client executes them and sends each result with its + // original call ID in a continuation request for the same thread. On that next request, + // the server executes the stored calls rather than trusting calls replayed in client-supplied history. + // The executed server calls and their matching results then appear in the continuation stream + // as completed history, not as requests for the client to execute those server tools. + // WithInMemorySessionStore below is required to retain those calls between HTTP requests; + // without a session store, the deferred calls are lost when the client continues the run. + EnableInvocableFunctionBypassing = true, + ChatOptions = new ChatOptions + { + Instructions = "You are a helpful assistant.", + Tools = services.GetKeyedServices(name).ToList(), + }, + }, services: services)) .WithAITools( new HostedWebSearchTool(), AIFunctionFactory.Create( diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md index 30adeb1eef2..5085feb0f26 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md @@ -131,7 +131,22 @@ IChatClient chatClient = new OpenAIClient( .AsIChatClientWithStoredOutputDisabled(model: deploymentName); builder - .AddAIAgent("AGUIAssistant", "You are a helpful assistant.", chatClient) + .AddAIAgent("AGUIAssistant", (services, name) => chatClient.AsAIAgent(new ChatClientAgentOptions + { + Name = name, + // Mixed client/server calls otherwise leave both calls unexecuted on the server. + // Retain server calls in the session and expose only client calls. The client executes them + // and sends results with matching call IDs on the same thread's continuation request. + // The server then executes its saved calls and streams their call/result pairs as completed + // history, not requests for client execution. WithInMemorySessionStore preserves deferred calls + // between HTTP requests so execution does not rely on client-replayed server calls. + EnableInvocableFunctionBypassing = true, + ChatOptions = new ChatOptions + { + Instructions = "You are a helpful assistant.", + Tools = services.GetKeyedServices(name).ToList(), + }, + }, services: services)) .WithAITool(new HostedWebSearchTool()) .WithInMemorySessionStore(); @@ -144,6 +159,16 @@ This automatically handles: - Server-sent events (SSE) formatting - Thread and run management +### Mixed Client and Server Tools + +`AGUIServer` enables `ChatClientAgentOptions.EnableInvocableFunctionBypassing` and uses an in-memory session store. When a model response requests both a client tool and a non-approval-required server function, the server retains the pending server call in its session and sends only the client call for execution. After the client returns its result, the server resumes the stored call. + +The client result carries the original call ID and returns on a continuation request for the same thread. The server executes its saved call, and the server call and its matching result appear in the continuation stream as completed history; the client does not execute that server tool. + +Both bypassing and session persistence are required for this flow. `AGUIDojoServer` configures the same behavior for `PredictiveStateUpdatesAgent`, which combines server-side `write_document` with client-side `confirm_changes`. Session stores must be keyed by the agent's name. + +In-memory storage is for demonstration: it loses sessions on restart and has no size limit or eviction. Production hosts should use a persistent store and an `AgentIsolationKeyProvider` to isolate sessions by authenticated user. + ### Client Side The `AGUIClient` uses the `AGUIChatClient` to connect to the remote server: diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs index 82b2c59bd8f..c4c52a7cbe6 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs @@ -23,6 +23,7 @@ namespace Microsoft.Agents.AI; /// carried by an approval response. This decorator adds an extra layer of assurance above FICC: it guarantees that /// only approvals the framework actually requested are honored, and that an approved call runs with exactly the tool /// name and arguments that were surfaced for approval. +/// This binding also applies to tools that do not require human approval. /// /// /// This decorator sits above in the pipeline. On outbound responses it @@ -84,7 +85,7 @@ public override async Task GetResponseAsync( return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); } - messages = this.ValidateInboundApprovalResponses(messages, options, session); + messages = this.ValidateInboundApprovalResponses(messages, session); var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); @@ -109,7 +110,7 @@ public override async IAsyncEnumerable GetStreamingResponseA yield break; } - messages = this.ValidateInboundApprovalResponses(messages, options, session); + messages = this.ValidateInboundApprovalResponses(messages, session); List? emitted = null; @@ -172,7 +173,7 @@ private bool TryGetSession([NotNullWhen(true)] out AgentSession? session) /// function invocation middleware. That is deliberate: a payload whose approval was rejected surfaces as an /// error instead of silently continuing as though the call had never been requested. /// - private IEnumerable ValidateInboundApprovalResponses(IEnumerable messages, ChatOptions? options, AgentSession session) + private IEnumerable ValidateInboundApprovalResponses(IEnumerable messages, AgentSession session) { var messageList = messages as IList ?? new List(messages); @@ -222,18 +223,13 @@ private IEnumerable ValidateInboundApprovalResponses(IEnumerable? result = null; for (int i = 0; i < messageList.Count; i++) { var message = messageList[i]; - var mutableContentsBuffer = this.BindApprovalResponses(message, knownRequests, settledCallIds, approvalNotRequiredToolNames); + var mutableContentsBuffer = this.BindApprovalResponses(message, knownRequests, settledCallIds); if (mutableContentsBuffer is null) { @@ -274,8 +270,7 @@ private IEnumerable ValidateInboundApprovalResponses(IEnumerable? BindApprovalResponses( ChatMessage message, Dictionary knownRequests, - HashSet? settledCallIds, - HashSet approvalNotRequiredToolNames) + HashSet? settledCallIds) { var contents = message.Contents; List? mutableContentsBuffer = null; @@ -320,20 +315,9 @@ private IEnumerable ValidateInboundApprovalResponses(IEnumerable /// + /// Binding applies to responses for every tool category, including tools that do not require human approval. A + /// response takes effect only when its matching request was recorded in the current by + /// the framework. Request content supplied or replayed by the caller does not establish that binding, so hosts must + /// persist the agent session across requests that participate in the same continuation. + /// + /// /// Set this property to to disable this behavior. Keeping it enabled is recommended, as it - /// strengthens the human-in-the-loop approval control; disable it only when approval binding is enforced elsewhere. + /// strengthens tool-response provenance and human-in-the-loop approval controls; disable it only when equivalent + /// binding is enforced elsewhere. /// /// /// This option has no effect when is . diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs index eab79cefa69..b97a667cd6a 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs @@ -199,6 +199,12 @@ public static ChatClientBuilder UseApprovalNotRequiredFunctionBypassing(this Cha /// approve. /// /// + /// Binding applies to responses for every tool category, including tools that do not require human approval. A + /// response takes effect only when its matching request was recorded in the current by + /// the framework. Request content supplied or replayed by the caller does not establish that binding, so hosts must + /// persist the agent session across requests that participate in the same continuation. + /// + /// /// This extension method is intended for use with custom chat client stacks when /// is . /// When is (the default), diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs index b19ed3b9c92..04cb411e83d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs @@ -207,28 +207,41 @@ public async Task ClientTriggersMultipleFunctionCallsAsync() Assert.Equal(2, functionResults.Count); } + /// + /// Verifies that both tools are invoked once when the model requests client and server tools in the same response. + /// The server tool does not require approval. Invocable function bypassing saves its call in the persisted + /// server session, so only the client call is initially sent to the client. + /// After the client executes its tool and returns the result, the server resumes the saved call. + /// [Fact] - public async Task ServerAndClientTriggerFunctionCallsSimultaneouslyAsync() + public async Task MixedToolCalls_InvokeBothServerAndClientToolsAsync() { // Arrange - int serverCallCount = 0; - int clientCallCount = 0; + List invocationOrder = []; AIFunction serverTool = AIFunctionFactory.Create(() => { System.Diagnostics.Debug.Assert(true, "Server function is being called!"); - serverCallCount++; + lock (invocationOrder) + { + invocationOrder.Add("GetServerData"); + } + return "Server data"; }, "GetServerData", "Gets data from the server"); AIFunction clientTool = AIFunctionFactory.Create(() => { System.Diagnostics.Debug.Assert(true, "Client function is being called!"); - clientCallCount++; + lock (invocationOrder) + { + invocationOrder.Add("GetClientData"); + } + return "Client data"; }, "GetClientData", "Gets data from the client"); - await this.SetupTestServerAsync(serverTools: [serverTool]); + await this.SetupTestServerAsync(serverTools: [serverTool], enableInvocableFunctionBypassing: true); var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: [clientTool]); AgentSession session = await agent.CreateSessionAsync(); @@ -256,14 +269,12 @@ public async Task ServerAndClientTriggerFunctionCallsSimultaneouslyAsync() } // Assert - this._output.WriteLine($"serverCallCount={serverCallCount}, clientCallCount={clientCallCount}"); + this._output.WriteLine($"invocationOrder={string.Join(", ", invocationOrder)}"); - // Verify both the server and client tools executed and both results round-tripped through - // the streaming pipeline. This is now correct behavior thanks to - // ConfigureForMixedInvocation in the AGUI.Hosting.AspNetCore package. + // Verify the stored server call resumes after the client result and both results round-trip. - Assert.Equal(1, serverCallCount); - Assert.Equal(1, clientCallCount); + Assert.Equal(["GetClientData", "GetServerData"], invocationOrder); + Assert.DoesNotContain(updates.SelectMany(u => u.Contents), c => c is ErrorContent); var functionCallUpdates = updates.Where(u => u.Contents.Any(c => c is FunctionCallContent)).ToList(); Assert.NotEmpty(functionCallUpdates); @@ -501,12 +512,18 @@ public async Task ClientToolCallWithCustomArgumentsAsync() private async Task SetupTestServerAsync( IList? serverTools = null, bool triggerParallelCalls = false, - JsonSerializerOptions? jsonSerializerOptions = null) + JsonSerializerOptions? jsonSerializerOptions = null, + bool enableInvocableFunctionBypassing = false) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.Services.AddAGUIServer(); builder.WebHost.UseTestServer(); + if (enableInvocableFunctionBypassing) + { + builder.Services.AddKeyedSingleton("base-agent", new InMemoryAgentSessionStore()); + } + // Configure HTTP JSON options if custom serializer options provided if (jsonSerializerOptions?.TypeInfoResolver != null) { @@ -517,7 +534,13 @@ private async Task SetupTestServerAsync( this._app = builder.Build(); // FakeChatClient will receive options.Tools containing both server and client tools (merged by framework) var fakeChatClient = new FakeToolCallingChatClient(triggerParallelCalls, this._output, jsonSerializerOptions: jsonSerializerOptions); - AIAgent baseAgent = fakeChatClient.AsAIAgent(instructions: null, name: "base-agent", description: "A base agent for tool testing", tools: serverTools ?? []); + AIAgent baseAgent = fakeChatClient.AsAIAgent(new ChatClientAgentOptions + { + Name = "base-agent", + Description = "A base agent for tool testing", + EnableInvocableFunctionBypassing = enableInvocableFunctionBypassing, + ChatOptions = new ChatOptions { Tools = serverTools ?? [] }, + }); this._app.MapAGUIServer("/agent", baseAgent); await this._app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs index c1dfdf0fe93..26e28d019c7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs @@ -171,6 +171,7 @@ public async Task GetResponseAsync_DuplicateMatchingResponsesInOneTurn_HonoredOn // Arrange — one recorded request, but the caller sends two responses with the same request id. var session = new ChatClientAgentSession(); var recordedCall = new FunctionCallContent("call1", "toolA"); + var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "toolA")] }; await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, recordedCall)); var first = new ToolApprovalResponseContent(RequestId, approved: true, recordedCall); @@ -180,7 +181,7 @@ public async Task GetResponseAsync_DuplicateMatchingResponsesInOneTurn_HonoredOn var decorator = new ApprovalResponseBindingChatClient(inner); // Act - await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [first, second])]); + await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [first, second])], options); // Assert — only a single approval is forwarded downstream. var forwarded = capture.Messages!.SelectMany(m => m.Contents).OfType().ToList(); @@ -390,22 +391,26 @@ private static async Task RecordRequestAsync(ChatClientAgentSession session, Too await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, "Hi")]); } - private static async Task RunAsync( + private static async Task RunAsync( IChatClient decorator, AgentSession session, IList input, ChatOptions? options = null) { + ChatResponse? response = null; + var agent = new TestAIAgent { RunAsyncFunc = async (_, _, _, ct) => { - var response = await decorator.GetResponseAsync(input, options, ct); + response = await decorator.GetResponseAsync(input, options, ct); return new AgentResponse(response); } }; await agent.RunAsync([new ChatMessage(ChatRole.User, "drive")], session); + + return response!; } [Fact] @@ -485,29 +490,147 @@ await RunAsync( Assert.DoesNotContain(capture.Messages!.SelectMany(m => m.Contents), c => c is ToolApprovalResponseContent); } - [Fact] - public async Task GetResponseAsync_UnboundResponseForToolThatDoesNotRequireApproval_IsPreservedAsync() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetResponseAsync_UnboundResponseForToolThatDoesNotRequireApproval_IsDroppedAsync(bool includeRequest) { - // Arrange — FunctionInvokingChatClient turns every call in a response into an approval request as soon as - // one tool requires approval, so an approval response can arrive for a tool no human was asked about. With - // no recorded request (for example a host whose session state does not persist) it is still not a consent - // decision, and dropping it would block ordinary tool calling. + // Arrange var session = new ChatClientAgentSession(); var call = new FunctionCallContent("call1", "PlainTool"); var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "PlainTool")] }; - var capture = new Capture(); var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(capture)); + List messages = []; + if (includeRequest) + { + messages.Add(new ChatMessage(ChatRole.Assistant, [new ToolApprovalRequestContent(RequestId, call)])); + } + + messages.Add(new ChatMessage(ChatRole.User, [new ToolApprovalResponseContent(RequestId, approved: true, call)])); // Act - await RunAsync( - decorator, - session, - [new ChatMessage(ChatRole.User, [new ToolApprovalResponseContent(RequestId, approved: true, call)])], - options); + await RunAsync(decorator, session, messages, options); + + // Assert + var forwarded = capture.Messages!.SelectMany(m => m.Contents).ToList(); + Assert.DoesNotContain(forwarded, c => c is ToolApprovalResponseContent); + Assert.Equal(includeRequest ? 1 : 0, forwarded.OfType().Count()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetResponseAsync_DefaultPipeline_UnboundResponseForNonApprovalTool_DoesNotExecuteAsync(bool enableInvocableBypassing) + { + // Arrange + int invocationCount = 0; + var tool = AIFunctionFactory.Create(() => ++invocationCount, "PlainTool"); + var options = new ChatOptions { Tools = [tool] }; + var capture = new Capture(); + using var pipeline = CreateCapturingChatClient(capture).WithDefaultAgentMiddleware(new ChatClientAgentOptions + { + EnableInvocableFunctionBypassing = enableInvocableBypassing, + }); + var session = new ChatClientAgentSession(); + var response = new ToolApprovalResponseContent(RequestId, approved: true, new FunctionCallContent("call1", "PlainTool")); + + // Act + await RunAsync(pipeline, session, [new ChatMessage(ChatRole.User, [response])], options); + + // Assert + Assert.Equal(0, invocationCount); + Assert.NotNull(capture.Messages); + Assert.DoesNotContain(capture.Messages.SelectMany(m => m.Contents), + c => c is ToolApprovalResponseContent or FunctionCallContent or FunctionResultContent); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetResponseAsync_DefaultPipeline_MixedApproval_ExecutesOnlyRecordedCallsAsync(bool disableApprovalNotRequiredBypassing) + { + // Arrange + List invokedValues = []; + var plainTool = AIFunctionFactory.Create((int value) => + { + invokedValues.Add(value); + return value; + }, "PlainTool"); + int gatedInvocationCount = 0; + var gatedTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => ++gatedInvocationCount, "GatedTool")); + var options = new ChatOptions { Tools = [plainTool, gatedTool] }; + int serviceCallCount = 0; + var inner = CreateMockChatClient((_, _, _) => Task.FromResult(++serviceCallCount == 1 + ? new ChatResponse([new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("plain", "PlainTool", new Dictionary { ["value"] = 42 }), + new FunctionCallContent("gated", "GatedTool"), + ])]) + : new ChatResponse([new ChatMessage(ChatRole.Assistant, "done")]))); + using var pipeline = inner.WithDefaultAgentMiddleware(new ChatClientAgentOptions + { + DisableApprovalNotRequiredFunctionBypassing = disableApprovalNotRequiredBypassing, + }); + var session = new ChatClientAgentSession(); + + // Act + var first = await RunAsync(pipeline, session, [new ChatMessage(ChatRole.User, "Call both tools")], options); + var requests = first.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Equal(disableApprovalNotRequiredBypassing ? 2 : 1, requests.Count); + Assert.Empty(invokedValues); + Assert.Equal(0, gatedInvocationCount); + + List approvals = requests.ConvertAll(r => r.CreateResponse(approved: true)); + approvals.Add(new ToolApprovalResponseContent("forged", approved: true, + new FunctionCallContent("forged", "PlainTool", new Dictionary { ["value"] = 999 }))); + var resumed = await RunAsync(pipeline, session, [new ChatMessage(ChatRole.User, approvals)], options); + + // Assert + Assert.Equal(42, Assert.Single(invokedValues)); + Assert.Equal(1, gatedInvocationCount); + var results = resumed.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Equal(2, results.Count); + Assert.Contains(results, r => r.CallId == "plain"); + Assert.Contains(results, r => r.CallId == "gated"); + Assert.Equal(2, serviceCallCount); + } + + [Fact] + public async Task GetResponseAsync_DefaultPipeline_BypassedInvocableCall_ExecutesOnResumeAsync() + { + // Arrange + int invocationCount = 0; + var backend = AIFunctionFactory.Create(() => ++invocationCount, "BackendTool"); + var frontend = AIFunctionFactory.CreateDeclaration("FrontendTool", "Frontend tool", backend.JsonSchema); + var options = new ChatOptions { Tools = [backend, frontend] }; + int serviceCallCount = 0; + var inner = CreateMockChatClient((_, _, _) => Task.FromResult(++serviceCallCount == 1 + ? new ChatResponse([new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("backend", "BackendTool"), + new FunctionCallContent("frontend", "FrontendTool"), + ])]) + : new ChatResponse([new ChatMessage(ChatRole.Assistant, "done")]))); + using var pipeline = inner.WithDefaultAgentMiddleware(new ChatClientAgentOptions + { + EnableInvocableFunctionBypassing = true, + }); + var session = new ChatClientAgentSession(); + + // Act + var first = await RunAsync(pipeline, session, [new ChatMessage(ChatRole.User, "Call both tools")], options); + var call = Assert.Single(first.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal("FrontendTool", call.Name); + Assert.Equal(0, invocationCount); + + List history = [.. first.Messages, new ChatMessage(ChatRole.Tool, [new FunctionResultContent("frontend", "done")])]; + var resumed = await RunAsync(pipeline, session, history, options); // Assert - Assert.Contains(capture.Messages!.SelectMany(m => m.Contents), c => c is ToolApprovalResponseContent { Approved: true }); + Assert.Equal(1, invocationCount); + Assert.Contains(resumed.Messages.SelectMany(m => m.Contents), c => c is FunctionResultContent { CallId: "backend" }); + Assert.Equal(2, serviceCallCount); } [Fact] From 173978ee93e0ffa5ef4ebdbfe2e94cd6f8e8a996 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:15:28 +0000 Subject: [PATCH 21/42] .NET: Add function replace support for function middleware (#8615) * Add function replace support for function middleware * Address PR comments. * Fix build errors * Address PR comments --- .../FunctionInvocationContextExtensions.cs | 52 ++ .../FunctionInvocationDelegatingAgent.cs | 151 +++- ...ocationDelegatingAgentBuilderExtensions.cs | 11 + .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 2 + .../netstandard2.0/PublicAPI.Unshipped.txt | 2 + .../FunctionInvocationDelegatingAgentTests.cs | 647 ++++++++++++++++++ 9 files changed, 861 insertions(+), 10 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/FunctionInvocationContextExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationContextExtensions.cs new file mode 100644 index 00000000000..79d22be81d3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationContextExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides extension methods for the instances that are passed to the +/// function invocation callbacks registered with . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class FunctionInvocationContextExtensions +{ + /// + /// Wraps the provided with the function invocation callbacks that have not run yet + /// for the invocation represented by . + /// + /// The context passed to the function invocation callback that is currently running. + /// The function to wrap. + /// + /// The wrapped function, or itself when no other callbacks are pending for this invocation. + /// + /// or is . + /// + /// No function invocation callback is running for , or the running callback already + /// called its continuation. + /// + /// + /// + /// A callback that assigns a different function to replaces the + /// function that the continuation invokes. The replacement is invoked directly, so the callbacks registered after + /// the one performing the replacement do not observe that invocation. Use this method to wrap the replacement so + /// that those callbacks run for it as well. + /// + /// + /// This method must be called by a function invocation callback that is running for , + /// and before that callback calls its continuation. Once the continuation ran, the callbacks it went through + /// are no longer pending, so the callbacks to wrap can no longer be determined. + /// + /// + public static AIFunction WrapWithPendingMiddleware(this FunctionInvocationContext context, AIFunction function) + { + _ = Throw.IfNull(context); + _ = Throw.IfNull(function); + + return FunctionInvocationDelegatingAgent.WrapWithPendingMiddleware(context, function); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs index 5fdadda9264..4f24815ee65 100644 --- a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs @@ -28,6 +28,17 @@ protected override Task RunCoreAsync(IEnumerable mes protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => this.InnerAgent.RunStreamingAsync(messages, session, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken); + /// + /// Wraps with the function invocation callbacks that have not yet run for the + /// invocation of . + /// + /// The context of the callback that is currently running. + /// The function to wrap. + /// The wrapped function, or when no further callbacks are pending. + /// No function invocation callback is running for . + internal static AIFunction WrapWithPendingMiddleware(FunctionInvocationContext context, AIFunction function) + => MiddlewareEnabledFunction.WrapWithPendingMiddleware(context, function); + // Work on a per-run copy so adding callback support does not change options that the caller may reuse. private ChatClientAgentRunOptions AgentRunOptionsWithFunctionMiddleware(AgentRunOptions? options) { @@ -201,10 +212,51 @@ internal static AIFunction Wrap(AIFunction function, FunctionInvocationDelegatin return new MiddlewareEnabledFunction(function, middleware, middlewareChain); } + internal static AIFunction WrapWithPendingMiddleware(FunctionInvocationContext context, AIFunction function) + { + for (var active = s_invocationScope.Value; active is not null; active = active.Parent) + { + // Execution contexts that escaped a callback keep a reference to its scope, so a scope is only + // usable while its callback runs. + if (!active.IsRunning || !ReferenceEquals(active.Context, context)) + { + continue; + } + + if (active.ContinuationInvoked) + { + throw new InvalidOperationException( + "The function invocation callbacks that have not run yet cannot be determined after the continuation was called. " + + $"Call {nameof(WrapWithPendingMiddleware)} before calling the continuation."); + } + + // Functions are wrapped from the last callback to the first, so the callbacks that + // still have to run for this invocation are the ones before the running callback. + var chain = active.MiddlewareChain; + var pendingCount = Array.IndexOf(chain, active.Middleware); + for (var i = 0; i < pendingCount; i++) + { + function = Wrap(function, chain[i], chain); + } + + return function; + } + + throw new InvalidOperationException( + $"No function invocation callback is running for the supplied {nameof(FunctionInvocationContext)}."); + } + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) { - var context = FunctionInvokingChatClient.CurrentContext - // Direct function calls have no context from FunctionInvokingChatClient, so create the values the callback expects. + var previous = s_invocationScope.Value; + + // Direct function calls have no context from FunctionInvokingChatClient, so create the values the + // callback expects. Wrappers entered while such a call runs share the context created for it, so + // that a callback redirecting the call to another wrapped function cannot re-enter itself endlessly. + var ambientContext = FunctionInvokingChatClient.CurrentContext; + bool isDirectCall = ambientContext is null; + var context = ambientContext + ?? FindDirectCallContext(previous) ?? new FunctionInvocationContext() { Arguments = arguments, @@ -212,7 +264,10 @@ internal static AIFunction Wrap(AIFunction function, FunctionInvocationDelegatin CallContent = new(string.Empty, this.InnerFunction.Name, new Dictionary(arguments)), }; - var previous = s_invocationScope.Value; + // The callback can redirect the call by assigning a different function to the context. + var targetBeforeCallback = context.Function; + InvocationScope? scope = null; + // A custom function wrapper can lead back to this callback during the same call. Run it only once. for (var active = previous; active is not null; active = active.Parent) { @@ -222,18 +277,29 @@ internal static AIFunction Wrap(AIFunction function, FunctionInvocationDelegatin } } - // Record the callback while it runs, including through wrappers that do not expose their inner function. - s_invocationScope.Value = new(context, this._middleware, previous); - // ChatOptions.Clone keeps function wrappers but copies the tool list into a plain collection. // In that case, use the callback list saved on this function. var middlewareChain = (context.Options?.Tools as MiddlewareEnabledTools)?.MiddlewareChain ?? this._middlewareChain; + + // Record the callback while it runs, including through wrappers that do not expose their inner function. + scope = new InvocationScope(context, this._middleware, middlewareChain, isDirectCall, previous); + s_invocationScope.Value = scope; + try { return await this._middleware._delegateFunc(this._middleware.InnerAgent, context, CoreLogicAsync, cancellationToken).ConfigureAwait(false); } finally { + // Execution contexts that escaped the callback still reference this scope. Mark it as finished + // so that they cannot keep using the callbacks of a callback that already returned. + scope.IsRunning = false; + + // Leave the context with the function it had on entry, so that the callbacks around this one and + // the function invocation loop, which reads the function for its telemetry once the invocation + // completed, keep seeing the function they requested. + context.Function = targetBeforeCallback; + // A function or callback can replace the entire tool list. Wrap the replacement before the next call. if (context.Options is { } options) { @@ -241,16 +307,81 @@ internal static AIFunction Wrap(AIFunction function, FunctionInvocationDelegatin } } - // Continue with the next function wrapper using any arguments changed by the callback. - ValueTask CoreLogicAsync(FunctionInvocationContext ctx, CancellationToken cancellationToken) - => base.InvokeCoreAsync(ctx.Arguments, cancellationToken); + // Continue with the function the callback selected, using any arguments it changed. + // Every callback in the chain sees the same ctx.Function: the outermost wrapper that + // FunctionInvokingChatClient resolved from the tool list. base.InvokeCoreAsync instead continues + // with this wrapper's inner function, which is the next step of the chain. Invoking ctx.Function + // while it still holds that wrapper would restart the chain from the top and recurse endlessly. + async ValueTask CoreLogicAsync(FunctionInvocationContext ctx, CancellationToken cancellationToken) + { + if (scope is not null) + { + scope.ContinuationInvoked = true; + } + + // The callback can call its continuation more than once, and the callbacks that run during one of + // those calls can select their own function. Start every call from the function this callback chose. + var target = ctx.Function; + try + { + return ReferenceEquals(target, targetBeforeCallback) + // The callback kept the target, so continue with the next function wrapper. + ? await base.InvokeCoreAsync(ctx.Arguments, cancellationToken).ConfigureAwait(false) + // The callback replaced the target with a function outside the chain, so invoke that instead. + : await target.InvokeAsync(ctx.Arguments, cancellationToken).ConfigureAwait(false); + } + finally + { + // Undo the selection of the callbacks that ran during this call so that another call to this + // continuation goes through them again instead of invoking the function they selected. + ctx.Function = target; + } + } + } + + /// Gets the context created for a direct function call that is still running, if there is one. + private static FunctionInvocationContext? FindDirectCallContext(InvocationScope? scope) + { + for (; scope is not null; scope = scope.Parent) + { + if (scope.IsRunning) + { + return scope.IsDirectCall ? scope.Context : null; + } + } + + return null; } private sealed class InvocationScope( - FunctionInvocationContext context, FunctionInvocationDelegatingAgent middleware, InvocationScope? parent) + FunctionInvocationContext context, + FunctionInvocationDelegatingAgent middleware, + FunctionInvocationDelegatingAgent[] middlewareChain, + bool isDirectCall, + InvocationScope? parent) { internal FunctionInvocationContext Context { get; } = context; internal FunctionInvocationDelegatingAgent Middleware { get; } = middleware; + internal FunctionInvocationDelegatingAgent[] MiddlewareChain { get; } = middlewareChain; + + /// + /// Gets a value indicating whether the context of this scope was created for a function that was + /// invoked without a context from . + /// + internal bool IsDirectCall { get; } = isDirectCall; + + /// + /// Gets or sets a value indicating whether the callback of this scope is still running. + /// + /// + /// Execution contexts that escape the callback keep a reference to this scope, so this state + /// tells them apart from the scope of a callback that is still running. + /// + internal bool IsRunning { get; set; } = true; + + /// Gets or sets a value indicating whether the callback of this scope called its continuation. + internal bool ContinuationInvoked { get; set; } + internal InvocationScope? Parent { get; } = parent; } } diff --git a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgentBuilderExtensions.cs index de207fdabcb..21ed067e973 100644 --- a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgentBuilderExtensions.cs @@ -30,6 +30,17 @@ public static class FunctionInvocationDelegatingAgentBuilderExtensions /// unless it intends to completely replace the function's behavior. /// /// + /// A callback can redirect the invocation by assigning a different to + /// before calling the continuation. The continuation then invokes + /// that function instead of the one that was requested, and restores the previous value before it returns, so a + /// callback that calls its continuation more than once goes through the same callbacks each time. The replacement + /// is invoked directly, so callbacks registered after the one performing the replacement do not observe that + /// invocation; use before calling the + /// continuation to have them run for the replacement as well. A replacement does not change how the function call + /// was resolved before the callbacks ran, so tool approval and telemetry still reflect the originally requested + /// function. + /// + /// /// The callbacks also apply to functions added to or replaced in the current /// collection during execution. Replacing the collection within a function or callback preserves the /// callbacks for subsequent invocations, including when the function-calling client clones the options diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 1ec4e243d09..fada975c025 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -22,3 +22,5 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions +[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index 1ec4e243d09..fada975c025 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -22,3 +22,5 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions +[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 1ec4e243d09..fada975c025 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -22,3 +22,5 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions +[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 1ec4e243d09..fada975c025 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -22,3 +22,5 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions +[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 1ec4e243d09..fada975c025 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -22,3 +22,5 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions +[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction! diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs index a4cd5363066..21cf1093105 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs @@ -2063,6 +2063,653 @@ public async Task RunAsync_WithBaseAgentRunOptions_PreservesAllOriginalOptionsAs #endregion + #region Function Replacement Tests + + /// + /// Tests that a callback replacing the context function has that replacement invoked by the continuation. + /// + [Fact] + public async Task RunAsync_MiddlewareReplacesFunction_ReplacementInvokedAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Replacement-Executed"); + return "Replacement result"; + }, "ReplacementFunction", "A replacement function"); + + var (mockChatClient, requests) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + context.Function = replacementFunction; + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await middleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.Equal(["Replacement-Executed"], executionOrder); + Assert.Equal(2, requests.Count); + Assert.Equal("Replacement result", GetFunctionResult(requests[1])); + } + + /// + /// Tests that a callback replacing the context function has that replacement invoked during streaming runs. + /// + [Fact] + public async Task RunStreamingAsync_MiddlewareReplacesFunction_ReplacementInvokedAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Replacement-Executed"); + return "Replacement result"; + }, "ReplacementFunction", "A replacement function"); + + var (mockChatClient, requests) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + context.Function = replacementFunction; + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + var updates = new List(); + await foreach (var update in middleware.RunStreamingAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None)) + { + updates.Add(update); + } + + // Assert + Assert.NotEmpty(updates); + Assert.Equal(["Replacement-Executed"], executionOrder); + Assert.Equal(2, requests.Count); + Assert.Equal("Replacement result", GetFunctionResult(requests[1])); + } + + /// + /// Tests that a replacement made by the first callback is invoked without running the later callbacks. + /// + [Fact] + public async Task RunAsync_FirstMiddlewareReplacesFunction_LaterMiddlewareNotInvokedAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Replacement-Executed"); + return "Replacement result"; + }, "ReplacementFunction", "A replacement function"); + + var (mockChatClient, requests) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + ValueTask FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("First-Pre"); + context.Function = replacementFunction; + return next(context, cancellationToken); + } + + ValueTask SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Second-Pre"); + return next(context, cancellationToken); + } + + var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync); + var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await secondMiddleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.Equal(["First-Pre", "Replacement-Executed"], executionOrder); + Assert.Equal(2, requests.Count); + Assert.Equal("Replacement result", GetFunctionResult(requests[1])); + } + + /// + /// Tests that a replacement made by the last callback is invoked instead of the original function. + /// + [Fact] + public async Task RunAsync_LastMiddlewareReplacesFunction_ReplacementInvokedAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Replacement-Executed"); + return "Replacement result"; + }, "ReplacementFunction", "A replacement function"); + + var (mockChatClient, requests) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + async ValueTask FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("First-Pre"); + var result = await next(context, cancellationToken); + executionOrder.Add("First-Post"); + return result; + } + + ValueTask SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Second-Pre"); + context.Function = replacementFunction; + return next(context, cancellationToken); + } + + var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync); + var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await secondMiddleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.Equal(["First-Pre", "Second-Pre", "Replacement-Executed", "First-Post"], executionOrder); + Assert.Equal("Replacement result", GetFunctionResult(requests[1])); + } + + /// + /// Tests that wrapping a replacement with the pending callbacks lets the later callbacks run for it. + /// + [Fact] + public async Task RunAsync_ReplacementWrappedWithPendingMiddleware_LaterMiddlewareInvokedAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Replacement-Executed"); + return "Replacement result"; + }, "ReplacementFunction", "A replacement function"); + + var (mockChatClient, requests) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + ValueTask FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("First-Pre"); + context.Function = context.WrapWithPendingMiddleware(replacementFunction); + return next(context, cancellationToken); + } + + async ValueTask SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Second-Pre"); + var result = await next(context, cancellationToken); + executionOrder.Add("Second-Post"); + return result; + } + + var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync); + var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await secondMiddleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.Equal(["First-Pre", "Second-Pre", "Replacement-Executed", "Second-Post"], executionOrder); + Assert.Equal("Replacement result", GetFunctionResult(requests[1])); + } + + /// + /// Tests that wrapping a replacement returns it unchanged when no other callbacks are pending. + /// + [Fact] + public async Task RunAsync_WrapWithPendingMiddleware_NoPendingMiddleware_ReturnsSameFunctionAsync() + { + // Arrange + var originalFunction = AIFunctionFactory.Create(() => "Original result", "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => "Replacement result", "ReplacementFunction", "A replacement function"); + AIFunction? wrappedFunction = null; + + var (mockChatClient, _) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + wrappedFunction = context.WrapWithPendingMiddleware(replacementFunction); + context.Function = wrappedFunction; + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await middleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.Same(replacementFunction, wrappedFunction); + } + + /// + /// Tests that a replacement is invoked when the function is called without an ambient invocation context. + /// + [Fact] + public async Task RunAsync_DirectFunctionInvocation_MiddlewareReplacesFunctionAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Replacement-Executed"); + return "Replacement result"; + }, "ReplacementFunction", "A replacement function"); + + object? directResult = null; + var mockChatClient = new Mock(); + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(GetResponseAsync); + + async Task GetResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken ct) + { + // Invoke the function directly so that no FunctionInvokingChatClient context exists. + if (options?.Tools?.FirstOrDefault() is AIFunction function) + { + directResult = await function.InvokeAsync([], ct); + } + + return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response after direct invocation")]); + } + + var innerAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + UseProvidedChatClientAsIs = true + }); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + context.Function = replacementFunction; + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await middleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.Equal(["Replacement-Executed"], executionOrder); + Assert.Equal("Replacement result", directResult?.ToString()); + } + + /// + /// Tests that the original function is still invoked when a callback leaves the context function unchanged. + /// + [Fact] + public async Task RunAsync_MiddlewareKeepsFunction_OriginalInvokedAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + + var (mockChatClient, requests) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + AIFunction? requestedFunction = null; + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + // Reassigning the same function must not change which function is invoked. + requestedFunction = context.Function; + context.Function = requestedFunction; + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await middleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.NotNull(requestedFunction); + Assert.Equal(["Original-Executed"], executionOrder); + Assert.Equal("Original result", GetFunctionResult(requests[1])); + } + + /// + /// Tests that wrapping a function outside of a callback invocation throws. + /// + [Fact] + public void WrapWithPendingMiddleware_NoActiveInvocation_Throws() + { + // Arrange + var function = AIFunctionFactory.Create(() => "Result", "TestFunction", "A test function"); + var context = new FunctionInvocationContext { Function = function }; + + // Act & Assert + Assert.Throws(() => context.WrapWithPendingMiddleware(function)); + } + + /// + /// Tests that wrapping validates its arguments. + /// + [Fact] + public void WrapWithPendingMiddleware_NullArguments_ThrowsArgumentNullException() + { + // Arrange + var function = AIFunctionFactory.Create(() => "Result", "TestFunction", "A test function"); + var context = new FunctionInvocationContext { Function = function }; + + // Act & Assert + Assert.Throws(() => ((FunctionInvocationContext)null!).WrapWithPendingMiddleware(function)); + Assert.Throws(() => context.WrapWithPendingMiddleware(null!)); + } + + /// + /// Tests that wrapping throws once the callback called its continuation, because the pending callbacks ran. + /// + [Fact] + public async Task RunAsync_WrapWithPendingMiddleware_AfterContinuation_ThrowsAsync() + { + // Arrange + var originalFunction = AIFunctionFactory.Create(() => "Original result", "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => "Replacement result", "ReplacementFunction", "A replacement function"); + Exception? capturedException = null; + + var (mockChatClient, _) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + async ValueTask FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + var result = await next(context, cancellationToken); + capturedException = Record.Exception(() => context.WrapWithPendingMiddleware(replacementFunction)); + return result; + } + + ValueTask SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + => next(context, cancellationToken); + + var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync); + var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await secondMiddleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.IsType(capturedException); + } + + /// + /// Tests that wrapping throws from an execution context that outlived the callback that created it. + /// + [Fact] + public async Task RunAsync_WrapWithPendingMiddleware_FromEscapedExecutionContext_ThrowsAsync() + { + // Arrange + var originalFunction = AIFunctionFactory.Create(() => "Original result", "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => "Replacement result", "ReplacementFunction", "A replacement function"); + var callbackReturned = new TaskCompletionSource(); + Task? escapedWork = null; + + var (mockChatClient, _) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + // Background work started by the callback inherits its execution context, and therefore its scope. + escapedWork = Task.Run(async () => + { + await callbackReturned.Task; + return Record.Exception(() => context.WrapWithPendingMiddleware(replacementFunction)); + }); + + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await middleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + callbackReturned.SetResult(true); + + // Assert + Assert.NotNull(escapedWork); + Assert.IsType(await escapedWork); + } + + /// + /// Tests that a callback calling its continuation twice runs the later callbacks for both calls, + /// even when one of them replaced the function. + /// + [Fact] + public async Task RunAsync_RepeatedContinuation_AfterReplacement_InvokesLaterMiddlewareAgainAsync() + { + // Arrange + var executionOrder = new List(); + var originalFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Original-Executed"); + return "Original result"; + }, "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Replacement-Executed"); + return "Replacement result"; + }, "ReplacementFunction", "A replacement function"); + + var (mockChatClient, _) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + async ValueTask FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("First-Pre"); + _ = await next(context, cancellationToken); + executionOrder.Add("First-Between"); + return await next(context, cancellationToken); + } + + ValueTask SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Second-Pre"); + context.Function = replacementFunction; + return next(context, cancellationToken); + } + + var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync); + var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await secondMiddleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert - the second call goes through the later callback again instead of invoking its replacement directly. + Assert.Equal( + ["First-Pre", "Second-Pre", "Replacement-Executed", "First-Between", "Second-Pre", "Replacement-Executed"], + executionOrder); + Assert.DoesNotContain("Original-Executed", executionOrder); + } + + /// + /// Tests that the function requested by the model is restored on the context once the callbacks completed. + /// + [Fact] + public async Task RunAsync_MiddlewareReplacesFunction_RequestedFunctionRestoredAsync() + { + // Arrange + var originalFunction = AIFunctionFactory.Create(() => "Original result", "TestFunction", "A test function"); + var replacementFunction = AIFunctionFactory.Create(() => "Replacement result", "ReplacementFunction", "A replacement function"); + AIFunction? requestedFunction = null; + FunctionInvocationContext? capturedContext = null; + + var (mockChatClient, _) = CreateMockChatClientForFunctionCall("TestFunction"); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + capturedContext = context; + requestedFunction = context.Function; + context.Function = replacementFunction; + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [originalFunction] }); + await middleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert - telemetry and logging read the function after the invocation, so it must be the requested one. + Assert.NotNull(capturedContext); + Assert.Same(requestedFunction, capturedContext.Function); + } + + /// + /// Tests that redirecting a directly invoked function to another wrapped function completes + /// instead of re-entering the same callback endlessly. + /// + [Fact] + public async Task RunAsync_DirectFunctionInvocation_ReplacementIsAnotherWrappedFunction_DoesNotRecurseAsync() + { + // Arrange + var callbackCount = 0; + var firstFunction = AIFunctionFactory.Create(() => "First result", "FirstFunction", "The requested function"); + var secondFunction = AIFunctionFactory.Create(() => "Second result", "SecondFunction", "The function to redirect to"); + object? directResult = null; + + AIFunction? wrappedSecondFunction = null; + var mockChatClient = new Mock(); + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(GetResponseAsync); + + async Task GetResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken ct) + { + // Invoke the wrapped function directly so that no FunctionInvokingChatClient context exists. + if (options?.Tools is { Count: 2 } tools) + { + wrappedSecondFunction = (AIFunction)tools[1]; + directResult = await ((AIFunction)tools[0]).InvokeAsync([], ct); + } + + return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response after direct invocation")]); + } + + var innerAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + UseProvidedChatClientAsIs = true + }); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + callbackCount++; + + // Unconditionally redirect to the other wrapped tool, which leads back to this callback. + context.Function = wrappedSecondFunction!; + return next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [firstFunction, secondFunction] }); + await middleware.RunAsync([new(ChatRole.User, "Test message")], null, options, CancellationToken.None); + + // Assert + Assert.Equal("Second result", directResult?.ToString()); + Assert.Equal(1, callbackCount); + } + + #endregion + + /// + /// Creates a mock IChatClient that requests a single function call and then completes, capturing every request. + /// + /// The name of the function to request. + /// The mock and the list of message sequences received by it. + private static (Mock Mock, List> Requests) CreateMockChatClientForFunctionCall(string functionName) + { + var mockChatClient = new Mock(); + var requests = new List>(); + + ChatResponse CreateResponse(IEnumerable messages) + { + requests.Add([.. messages]); + return requests.Count == 1 + ? new ChatResponse([new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_123", functionName, new Dictionary())])]) + : new ChatResponse([new ChatMessage(ChatRole.Assistant, "Final response")]); + } + + mockChatClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) => CreateResponse(messages)); + + mockChatClient.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) => + CreateResponse(messages).ToChatResponseUpdates().ToAsyncEnumerable()); + + return (mockChatClient, requests); + } + + /// + /// Gets the single function result sent back to the chat client. + /// + /// The messages of a request received by the chat client. + /// The function result value. + private static string? GetFunctionResult(List messages) + => Assert.Single(messages.SelectMany(m => m.Contents).OfType()).Result?.ToString(); + private static async Task<(IChatClient Client, Mock Mock, Queue Responses)> CaptureClientAsync(List invocations) { var responses = new Queue([new(new ChatMessage(ChatRole.Assistant, "Initialized"))]); From 736e838dc61628a6dd4185feaa13d781a23e0a01 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:29:15 +0000 Subject: [PATCH 22/42] Python: .NET: Keep A2A streaming session state current (#8646) * Require established sessions for A2A continuations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Keep A2A streaming session state current Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Align A2A continuation context validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Preserve task-only A2A continuations and streaming state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Simplify A2A streaming context validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Validate A2A streaming context before yielding Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 19 ++++-- .../A2AAgentTests.cs | 44 +++++++++++--- .../a2a/agent_framework_a2a/_agent.py | 28 +++++++-- python/packages/a2a/tests/test_a2a_agent.py | 59 +++++++++++++++---- 4 files changed, 120 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 921412ba007..d66bc66e337 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -201,6 +201,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA case StreamResponseCase.Message: var message = streamResponse.Message!; contextId = message.ContextId; + ValidateContext(typedSession, contextId); yield return this.ConvertToAgentResponseUpdate(message); break; @@ -209,6 +210,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA contextId = task.ContextId; taskId = task.Id; taskState = task.Status.State; + ValidateContext(typedSession, contextId); yield return this.ConvertToAgentResponseUpdate(task); break; @@ -217,6 +219,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA contextId = statusUpdate.ContextId; taskId = statusUpdate.TaskId; taskState = statusUpdate.Status.State; + ValidateContext(typedSession, contextId); yield return this.ConvertToAgentResponseUpdate(statusUpdate); break; @@ -224,6 +227,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA var artifactUpdate = streamResponse.ArtifactUpdate!; contextId = artifactUpdate.ContextId; taskId = artifactUpdate.TaskId; + ValidateContext(typedSession, contextId); yield return this.ConvertToAgentResponseUpdate(artifactUpdate); break; @@ -347,6 +351,16 @@ private static void UpdateSession(A2AAgentSession? session, string? contextId, s return; } + ValidateContext(session, contextId); + + // Assign a server-generated context Id to the session if it's not already set. + session.ContextId ??= contextId; + session.TaskId = taskId; + session.TaskState = taskState; + } + + private static void ValidateContext(A2AAgentSession session, string? contextId) + { // Surface cases where the A2A agent responds with a response that // has a different context Id than the session's conversation Id. if (session.ContextId is not null && contextId is not null && session.ContextId != contextId) @@ -354,11 +368,6 @@ private static void UpdateSession(A2AAgentSession? session, string? contextId, s throw new InvalidOperationException( $"The {nameof(contextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentSession)}."); } - - // Assign a server-generated context Id to the session if it's not already set. - session.ContextId ??= contextId; - session.TaskId = taskId; - session.TaskState = taskState; } private static Message CreateA2AMessage(A2AAgentSession typedSession, IReadOnlyCollection messages) diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 614d1b4dde0..c2788603215 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -406,8 +406,12 @@ public async Task RunStreamingAsync_WithExistingSession_SetConversationIdToMessa Assert.Equal("existing-context-id", message.ContextId); } - [Fact] - public async Task RunStreamingAsync_WithSessionHavingDifferentContextId_ThrowsInvalidOperationExceptionAsync() + [Theory] + [InlineData(StreamResponseCase.Message)] + [InlineData(StreamResponseCase.Task)] + [InlineData(StreamResponseCase.StatusUpdate)] + [InlineData(StreamResponseCase.ArtifactUpdate)] + public async Task RunStreamingAsync_WithSessionHavingDifferentContextId_ThrowsInvalidOperationExceptionAsync(StreamResponseCase responseCase) { // Arrange var session = await this._agent.CreateSessionAsync(); @@ -419,15 +423,36 @@ public async Task RunStreamingAsync_WithSessionHavingDifferentContextId_ThrowsIn new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new StreamResponse + this._handler.StreamingResponseToReturn = responseCase switch { - Message = new Message + StreamResponseCase.Message => new() { - MessageId = "stream-1", - Role = Role.Agent, - Parts = [new Part { Text = "Response" }], - ContextId = "different-context" - } + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "different-context" + } + }, + StreamResponseCase.Task => new() + { + Task = new AgentTask { Id = "task-1", ContextId = "different-context", Status = new() { State = TaskState.Working } } + }, + StreamResponseCase.StatusUpdate => new() + { + StatusUpdate = new TaskStatusUpdateEvent { TaskId = "task-1", ContextId = "different-context", Status = new() { State = TaskState.Working } } + }, + StreamResponseCase.ArtifactUpdate => new() + { + ArtifactUpdate = new TaskArtifactUpdateEvent + { + TaskId = "task-1", + ContextId = "different-context", + Artifact = new Artifact { ArtifactId = "artifact-1", Parts = [new Part { Text = "Response" }] } + } + }, + _ => throw new ArgumentOutOfRangeException(nameof(responseCase)) }; // Act @@ -435,6 +460,7 @@ await Assert.ThrowsAsync(async () => { await foreach (var update in this._agent.RunStreamingAsync(inputMessages, session)) { + Assert.Fail("A mismatched context must not yield an update."); } }); } diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 1cfe7c12443..c5780b1ed5f 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -411,6 +411,23 @@ def _extract_a2a_session_state( service_session_id.get("task_state"), ) + def _validate_context(self, session: AgentSession | None, context_id: str | None) -> None: + """Ensure a context_id returned by the A2A agent matches the session's context_id. + + Args: + session: The session the run was started with, if any. + context_id: The context_id returned by the A2A agent, if any. + + Raises: + RuntimeError: If the session is already bound to a different context_id. + """ + existing_context_id, _, _ = self._extract_a2a_session_state(session) + if existing_context_id is not None and context_id and existing_context_id != context_id: + raise RuntimeError( + f"The context_id returned from the A2A agent ('{context_id}') " + f"differs from the session's context_id ('{existing_context_id}')." + ) + def _get_otel_conversation_id(self, session: AgentSession | None) -> str | None: """Return A2A context_id as OpenTelemetry conversation id.""" context_id, _, _ = self._extract_a2a_session_state(session) @@ -600,6 +617,7 @@ async def _map_a2a_stream( if payload_type == "message": # Process A2A Message msg = item.message + self._validate_context(session, msg.context_id) if msg.context_id: last_context_id = msg.context_id contents = self._parse_contents_from_a2a(msg.parts) @@ -616,6 +634,7 @@ async def _map_a2a_stream( yield update elif payload_type == "task": task = item.task + self._validate_context(session, task.context_id) last_task_id = task.id if task.context_id: last_context_id = task.context_id @@ -643,6 +662,7 @@ async def _map_a2a_stream( yield update elif payload_type == "status_update": status_event = item.status_update + self._validate_context(session, status_event.context_id) last_task_id = status_event.task_id if status_event.context_id: last_context_id = status_event.context_id @@ -678,6 +698,7 @@ async def _map_a2a_stream( pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates) elif payload_type == "artifact_update": artifact_event = item.artifact_update + self._validate_context(session, artifact_event.context_id) last_task_id = artifact_event.task_id if artifact_event.context_id: last_context_id = artifact_event.context_id @@ -703,12 +724,7 @@ async def _map_a2a_stream( if session is not None and (last_task_id or last_context_id): existing_context_id, existing_task_id, existing_task_state = self._extract_a2a_session_state(session) - # Validate context_id consistency - if existing_context_id is not None and last_context_id and existing_context_id != last_context_id: - raise RuntimeError( - f"The context_id returned from the A2A agent ('{last_context_id}') " - f"differs from the session's context_id ('{existing_context_id}')." - ) + self._validate_context(session, last_context_id) persisted_context_id = existing_context_id or last_context_id if persisted_context_id is not None: diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 0fc5b3007c9..533e2e2a667 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -2379,19 +2379,58 @@ async def test_context_id_tracked_from_message_payload(mock_a2a_client: MockA2AC assert session.task_id is None -@mark.asyncio -async def test_context_id_mismatch_raises_error(mock_a2a_client: MockA2AClient) -> None: - """Test that a context_id mismatch between session and response raises an error.""" +@mark.parametrize("stream", [False, True]) +@mark.parametrize("payload_type", ["message", "task", "status_update", "artifact_update"]) +async def test_context_id_mismatch_raises_error( + mock_a2a_client: MockA2AClient, stream: bool, payload_type: str +) -> None: + """A context mismatch must raise before yielding any response updates.""" agent = A2AAgent(name="Test Agent", id="test-agent", client=cast(Any, mock_a2a_client), http_client=None) - - # Task response has context_id="test-context" (from add_task_response helper) - mock_a2a_client.add_task_response("task-mismatch", [{"content": "Reply"}]) - - # Session already has a different context_id - session = A2AAgentSession(context_id="different-context") + message = A2AMessage( + message_id="message-1", + context_id="test-context", + role=A2ARole.ROLE_AGENT, + parts=[Part(text="Reply")], + ) + artifact = Artifact(artifact_id="artifact-1", parts=[Part(text="Reply")]) + responses = { + "message": StreamResponse(message=message), + "task": StreamResponse( + task=Task( + id="task-1", + context_id="test-context", + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), + artifacts=[artifact], + ) + ), + "status_update": StreamResponse( + status_update=TaskStatusUpdateEvent( + task_id="task-1", + context_id="test-context", + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=message), + ) + ), + "artifact_update": StreamResponse( + artifact_update=TaskArtifactUpdateEvent( + task_id="task-1", + context_id="test-context", + artifact=artifact, + ) + ), + } + mock_a2a_client.responses.append(responses[payload_type]) + session = AgentSession(service_session_id="different-context") + updates: list[AgentResponseUpdate] = [] with raises(RuntimeError, match="differs from the session's context_id"): - await agent.run("Hello", session=session) + if stream: + async for update in agent.run("Hello", session=session, stream=True): + updates.append(update) + else: + await agent.run("Hello", session=session) + + assert updates == [] + assert session.service_session_id == "different-context" @mark.asyncio From 74e8fe6da942ee991819ea861de1841243d7c0e1 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:48:58 +0000 Subject: [PATCH 23/42] Use the workflow token for fix-ci evidence (#8667) * Use the workflow token for fix-ci evidence * Tighten fix-ci token permissions --- .github/tests/test_devflow_fix_ci_workflow.js | 36 +++++++++++++++++++ .github/workflows/devflow-fix-ci.yml | 10 +++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/tests/test_devflow_fix_ci_workflow.js b/.github/tests/test_devflow_fix_ci_workflow.js index e06529069de..54bdb90dc11 100644 --- a/.github/tests/test_devflow_fix_ci_workflow.js +++ b/.github/tests/test_devflow_fix_ci_workflow.js @@ -31,6 +31,42 @@ describe('DevFlow PR repair entrypoint', () => { assert.match(workflow, /content: 'eyes'/); }); + it('uses the review-proven App grant and the job token for Actions reads', () => { + assert.doesNotMatch(workflow, /actions-permission: read/); + assert.ok([...workflow.matchAll(/GITHUB_TOKEN: \$\{\{ github\.token \}\}/g)].length >= 3); + const authorization = workflow.slice( + workflow.indexOf('Get source-repository App token'), + workflow.indexOf('Authorize the frozen command requester'), + ); + assert.match(authorization, /contents-permission: read/); + assert.match(authorization, /issues-permission: write/); + assert.match(authorization, /pull-requests-permission: read/); + + const repair = workflow.slice(workflow.indexOf('\n repair:'), workflow.indexOf('\n approve:')); + assert.match(repair, /permissions:\n contents: read\n actions: read\n checks: read/); + assert.doesNotMatch(repair.slice(0, repair.indexOf(' outputs:')), /issues: write/); + const repairRead = repair.slice( + repair.indexOf('Get read-only source-repository App token'), + repair.indexOf('Generate and verify the bounded repair'), + ); + assert.match(repairRead, /contents-permission: read/); + assert.match(repairRead, /issues-permission: read/); + assert.match(repairRead, /pull-requests-permission: read/); + + const publish = workflow.slice(workflow.indexOf('\n publish:')); + assert.doesNotMatch(publish.slice(0, publish.indexOf(' env:')), /issues: write/); + const publishRead = publish.slice( + publish.indexOf('Get read-only source-repository App token'), + publish.indexOf('Revalidate exact candidate and approval before write authority'), + ); + assert.match(publishRead, /issues-permission: read/); + assert.match(publishRead, /pull-requests-permission: read/); + const publishWrite = publish.slice(publish.indexOf('Get source-repository write token')); + assert.match(publishWrite, /contents-permission: write/); + assert.match(publishWrite, /issues-permission: write/); + assert.match(publishWrite, /pull-requests-permission: read/); + }); + it('runs private policy code and preserves the exact-diff publication gate', () => { assert.match(workflow, /scripts\/trigger_fix_ci\.py --phase run/); assert.match(workflow, /environment: devflow-pr-repair-publish/); diff --git a/.github/workflows/devflow-fix-ci.yml b/.github/workflows/devflow-fix-ci.yml index 4fb132d41ce..105415fec21 100644 --- a/.github/workflows/devflow-fix-ci.yml +++ b/.github/workflows/devflow-fix-ci.yml @@ -96,7 +96,6 @@ jobs: github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} repository: ${{ github.repository }} contents-permission: read - actions-permission: read issues-permission: write pull-requests-permission: read @@ -163,6 +162,7 @@ jobs: GATEWAY_SHA256: ${{ needs.snapshot.outputs.gateway_sha256 }} DEVFLOW_SHA: ${{ steps.controller.outputs.sha }} GH_TOKEN: ${{ steps.auth.outputs.token }} + GITHUB_TOKEN: ${{ github.token }} DEVFLOW_PUBLISH_AUTH_SOURCE: ${{ steps.auth.outputs.source }} DEVFLOW_PUBLISH_AUTHORIZATION: ${{ steps.auth.outputs.authorization }} PR_REPAIR_TARGET_REPOSITORY: ${{ github.repository }} @@ -201,7 +201,7 @@ jobs: permissions: contents: read actions: read - issues: write + checks: read id-token: write copilot-requests: write outputs: @@ -261,7 +261,6 @@ jobs: github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} repository: ${{ github.repository }} contents-permission: read - actions-permission: read issues-permission: read pull-requests-permission: read - name: Generate and verify the bounded repair @@ -271,6 +270,7 @@ jobs: DEVFLOW_SHA: ${{ needs.authorize.outputs.controller_sha }} PR_REPAIR_TARGET_REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ steps.read-auth.outputs.token }} + GITHUB_TOKEN: ${{ github.token }} DEVFLOW_MODEL_TOKEN: ${{ github.token }} DEVFLOW_PUBLISH_AUTH_SOURCE: ${{ steps.read-auth.outputs.source }} DEVFLOW_PUBLISH_AUTHORIZATION: ${{ steps.read-auth.outputs.authorization }} @@ -378,7 +378,6 @@ jobs: permissions: contents: read actions: read - issues: write id-token: write env: SNAPSHOT_SHA256: ${{ needs.authorize.outputs.snapshot_sha256 }} @@ -386,6 +385,7 @@ jobs: PR_REPAIR_TARGET_REPOSITORY: ${{ github.repository }} PR_REPAIR_APPROVAL_ENVIRONMENT: ${{ needs.authorize.outputs.command == '/fix' && 'devflow-pr-repair-publish' || 'github-app-auth' }} DEVELOPER_TEAM: ${{ secrets.DEVELOPER_TEAM }} + GITHUB_TOKEN: ${{ github.token }} steps: - name: Checkout frozen public automation uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -441,7 +441,6 @@ jobs: github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} repository: ${{ github.repository }} contents-permission: read - actions-permission: read issues-permission: read pull-requests-permission: read - name: Revalidate exact candidate and approval before write authority @@ -469,7 +468,6 @@ jobs: github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} repository: ${{ github.repository }} contents-permission: write - actions-permission: read issues-permission: write pull-requests-permission: read - name: Publish only the revalidated candidate From 11b8b80a2c904a36603c4445df5ddb07a55e6817 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:55:08 +0000 Subject: [PATCH 24/42] Use review App permissions for repair reactions (#8669) --- .github/tests/test_devflow_fix_ci_workflow.js | 15 +++++++++++++-- .github/workflows/devflow-fix-ci.yml | 7 ++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/tests/test_devflow_fix_ci_workflow.js b/.github/tests/test_devflow_fix_ci_workflow.js index 54bdb90dc11..0944020fec7 100644 --- a/.github/tests/test_devflow_fix_ci_workflow.js +++ b/.github/tests/test_devflow_fix_ci_workflow.js @@ -27,6 +27,15 @@ describe('DevFlow PR repair entrypoint', () => { const intake = workflow.indexOf('Validate and bind the authorized request'); const reaction = workflow.indexOf('Acknowledge accepted repair command'); assert.ok(reaction > intake); + const authStep = workflow.slice( + workflow.indexOf('Get source-repository App token'), + workflow.indexOf('Authorize the frozen command requester'), + ); + assert.match(authStep, /pull-requests-permission: write/); + assert.doesNotMatch(workflow, /id: reaction-auth/); + const reactionStep = workflow.slice(reaction, workflow.indexOf('\n repair:')); + assert.match(reactionStep, /github-token: \$\{\{ steps\.auth\.outputs\.token \}\}/); + assert.match(reactionStep, /continue-on-error: true/); assert.match(workflow, /steps\.intake\.outputs\.mode == 'repair'/); assert.match(workflow, /content: 'eyes'/); }); @@ -40,7 +49,7 @@ describe('DevFlow PR repair entrypoint', () => { ); assert.match(authorization, /contents-permission: read/); assert.match(authorization, /issues-permission: write/); - assert.match(authorization, /pull-requests-permission: read/); + assert.match(authorization, /pull-requests-permission: write/); const repair = workflow.slice(workflow.indexOf('\n repair:'), workflow.indexOf('\n approve:')); assert.match(repair, /permissions:\n contents: read\n actions: read\n checks: read/); @@ -52,6 +61,8 @@ describe('DevFlow PR repair entrypoint', () => { assert.match(repairRead, /contents-permission: read/); assert.match(repairRead, /issues-permission: read/); assert.match(repairRead, /pull-requests-permission: read/); + const report = repair.slice(repair.indexOf('Get issue-reporting token')); + assert.match(report, /pull-requests-permission: write/); const publish = workflow.slice(workflow.indexOf('\n publish:')); assert.doesNotMatch(publish.slice(0, publish.indexOf(' env:')), /issues: write/); @@ -64,7 +75,7 @@ describe('DevFlow PR repair entrypoint', () => { const publishWrite = publish.slice(publish.indexOf('Get source-repository write token')); assert.match(publishWrite, /contents-permission: write/); assert.match(publishWrite, /issues-permission: write/); - assert.match(publishWrite, /pull-requests-permission: read/); + assert.match(publishWrite, /pull-requests-permission: write/); }); it('runs private policy code and preserves the exact-diff publication gate', () => { diff --git a/.github/workflows/devflow-fix-ci.yml b/.github/workflows/devflow-fix-ci.yml index 105415fec21..a1bddd939e2 100644 --- a/.github/workflows/devflow-fix-ci.yml +++ b/.github/workflows/devflow-fix-ci.yml @@ -97,7 +97,7 @@ jobs: repository: ${{ github.repository }} contents-permission: read issues-permission: write - pull-requests-permission: read + pull-requests-permission: write - name: Authorize the frozen command requester id: team @@ -182,6 +182,7 @@ jobs: - name: Acknowledge accepted repair command if: ${{ steps.intake.outputs.mode == 'repair' }} + continue-on-error: true uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.auth.outputs.token }} @@ -307,7 +308,7 @@ jobs: repository: ${{ github.repository }} contents-permission: read issues-permission: write - pull-requests-permission: read + pull-requests-permission: write - name: Report exact candidate and approval instructions if: ${{ steps.run.outputs.status == 'ready' }} env: @@ -469,7 +470,7 @@ jobs: repository: ${{ github.repository }} contents-permission: write issues-permission: write - pull-requests-permission: read + pull-requests-permission: write - name: Publish only the revalidated candidate env: GH_TOKEN: ${{ steps.write-auth.outputs.token }} From 9225e8d25ef1dfedc44146cefe3ad6a68ccc011b Mon Sep 17 00:00:00 2001 From: "Shivani ." Date: Wed, 23 Sep 2026 08:02:16 +0000 Subject: [PATCH 25/42] Python: Preserve structured output on streaming harness tool-approval path (#7418) (#8548) * Python: Preserve structured output on streaming harness tool-approval path Forward response_format through ToolApprovalMiddleware's stream finalizer so AgentResponse.value is parsed the same way as the non-streaming path. * Python: Keep terminal structured value after auto-approved tool preamble Return the last inner AgentResponse from the streaming tool-approval finalizer so coalesced preamble text cannot clobber response.value. * Potential fix for pull request finding 'Preserve terminal JSON value before checking lazy parse state' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Python: Address review on streaming structured-output finalizer Follow-up to #8548 review feedback: - Forward the terminal inner response's structured value through the public AgentResponse.from_updates(value=...) contract instead of mutating the private _value/_value_parsed fields. Those fields were only copied when the inner response had already parsed its value, so the normal lazy-parse case still reparsed the coalesced preamble and JSON as one invalid string. - Resolve final.value eagerly (which completes the lazy parse) and suppress the expected parse failures (ValueError/TypeError) so a parse error still surfaces lazily on value access, matching a middleware-free streaming run. - Cast default_options to Mapping[str, Any] in _structured_response_format so strict pyright no longer reports reportUnknownMemberType and reportUnknownVariableType (Package Checks). - Keep the parametrized regression test's option dicts out of Agent's generic client/options inference so mypy, pyrefly, ty, zuban, and pyright all pass (Test Typing Checks). --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg --- .../_harness/_tool_approval.py | 51 +++- .../tests/core/test_harness_tool_approval.py | 239 ++++++++++++++++++ 2 files changed, 288 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_tool_approval.py b/python/packages/core/agent_framework/_harness/_tool_approval.py index 4e2db951c77..6e33b205149 100644 --- a/python/packages/core/agent_framework/_harness/_tool_approval.py +++ b/python/packages/core/agent_framework/_harness/_tool_approval.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import copy import inspect import json @@ -77,6 +78,25 @@ def _contents_from_state(values: Any) -> list[Content]: return [_content_from_state(value) for value in state_items] +def _structured_response_format(context: AgentContext) -> Any | None: + """Return the structured-output schema for this invocation, if any. + + Run ``options`` take precedence over the agent's ``default_options``. The + streaming re-wrap in :meth:`ToolApprovalMiddleware._process_stream` must + forward this to ``AgentResponse.from_updates`` or ``response.value`` is + dropped even when the inner stream already parsed it. + """ + if context.options is not None: + response_format = context.options.get("response_format") + if response_format is not None: + return response_format + default_options = getattr(context.agent, "default_options", None) + if isinstance(default_options, Mapping): + typed_default_options = cast("Mapping[str, Any]", default_options) + return typed_default_options.get("response_format") + return None + + def _content_to_state(content: Content) -> dict[str, Any]: return content.to_dict() @@ -442,6 +462,14 @@ def _process_stream( call_next: Callable[[], Awaitable[None]], state: ToolApprovalState, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + # Last inner AgentResponse. The outer finalizer resolves its structured + # value (completing its lazy parse) and forwards it through the public + # ``from_updates(value=...)`` contract, so an auto-approved preamble that + # the outer aggregation coalesces into the JSON message cannot mask it + # (#7418). + holder: dict[str, AgentResponse | None] = {"final": None} + response_format = _structured_response_format(context) + async def _stream() -> AsyncIterable[AgentResponseUpdate]: if context.session is None: raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.") @@ -477,7 +505,7 @@ async def _stream() -> AsyncIterable[AgentResponseUpdate]: buffered_update = copy.copy(update) buffered_update.contents = list(update.contents) buffered_approval_updates.append(buffered_update) - await context.result.get_final_response() + holder["final"] = await context.result.get_final_response() if not approval_requests: return @@ -509,7 +537,26 @@ async def _stream() -> AsyncIterable[AgentResponseUpdate]: context.messages = [] context.result = None - return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: + # Build the response from the streamed updates so the middleware's + # approval / user-input handling is preserved. The coalesced update text + # can include preamble from auto-approved turns, which would otherwise be + # parsed as one invalid string, so resolve the terminal inner response's + # structured value — reading ``value`` also completes its lazy parse — and + # forward it via ``from_updates``' public ``value`` argument (#7418). A + # parse failure is left unset so the outer response still surfaces the + # error lazily on ``value`` access, matching the error timing of a + # middleware-free streaming run. + final = holder["final"] + value: Any = None + if final is not None: + # ``ValidationError`` subclasses ``ValueError``; ``TypeError`` covers a + # malformed ``response_format`` in the terminal inner response. + with contextlib.suppress(ValueError, TypeError): + value = final.value + return AgentResponse.from_updates(updates, output_format_type=response_format, value=value) + + return ResponseStream(_stream(), finalizer=_finalize) def _prepare_inbound_messages( self, diff --git a/python/packages/core/tests/core/test_harness_tool_approval.py b/python/packages/core/tests/core/test_harness_tool_approval.py index 1a96bd97088..c9a2a604bb2 100644 --- a/python/packages/core/tests/core/test_harness_tool_approval.py +++ b/python/packages/core/tests/core/test_harness_tool_approval.py @@ -3092,3 +3092,242 @@ def optional_args_tool(value: str = "default") -> str: requests = _approval_requests(second_response.messages) assert [_function_call(request).arguments for request in requests] == ['{"value": "custom"}'] assert calls == 1 + + +@pytest.mark.parametrize("via", ["run_options", "default_options"], ids=["run-options", "default-options"]) +async def test_streaming_tool_approval_preserves_structured_value( + chat_client_base: MockBaseChatClient, + via: str, +) -> None: + """Streaming ToolApprovalMiddleware must forward response_format to the outer finalizer. + + Regression for https://github.com/microsoft/agent-framework/issues/7418: + re-wrapping with ``AgentResponse.from_updates`` dropped ``output_format_type``, + so ``response.value`` was None even when the inner stream had parsed it. + """ + from pydantic import BaseModel + + class Answer(BaseModel): + answer: str + + json_text = '{"answer": "42"}' + + @tool(name="echo", approval_mode="always_require") + def echo(text: str) -> str: + return text + + # ``Any`` keeps the parametrized options out of ``Agent``'s generic client/options + # inference: the plain response-format dicts cannot be unified with the fixture's + # ``MockBaseChatClient[ChatOptions[None]]`` client type. + default_options: Any = {"response_format": Answer} if via == "default_options" else None + run_options: Any = {"response_format": Answer} if via == "run_options" else None + agent = Agent( + client=chat_client_base, + tools=[echo], + middleware=[ToolApprovalMiddleware()], + default_options=default_options, + ) + session = AgentSession(session_id=f"structured-stream-{via}") + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + role="assistant", + contents=[Content.from_text(json_text)], + finish_reason="stop", + ) + ] + ] + + stream = agent.run("return an Answer", stream=True, session=session, options=run_options) + async for _update in stream: + pass + response = await stream.get_final_response() + + assert response.text == json_text + assert isinstance(response.value, Answer) + assert response.value.answer == "42" + + +async def test_streaming_auto_approved_tool_preserves_structured_value( + chat_client_base: MockBaseChatClient, +) -> None: + """Auto-approved tool calls must still parse structured output on the streaming path.""" + from pydantic import BaseModel + + class Answer(BaseModel): + answer: str + + json_text = '{"answer": "42"}' + calls = 0 + + @tool(name="echo", approval_mode="always_require") + def echo(text: str) -> str: + nonlocal calls + calls += 1 + return text + + agent = Agent( + client=chat_client_base, + tools=[echo], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[lambda function_call: True])], + ) + session = AgentSession(session_id="structured-stream-auto-approve") + function_call = Content.from_function_call(call_id="call_echo", name="echo", arguments='{"text": "hi"}') + chat_client_base.streaming_responses = [ + [ChatResponseUpdate(role="assistant", contents=[function_call])], + [ + ChatResponseUpdate( + role="assistant", + contents=[Content.from_text(json_text)], + finish_reason="stop", + ) + ], + ] + + stream = agent.run( + "Call echo and return an Answer.", + stream=True, + session=session, + options={"response_format": Answer}, + ) + async for _update in stream: + pass + response = await stream.get_final_response() + + assert calls == 1 + assert isinstance(response.value, Answer) + assert response.value.answer == "42" + + +async def test_streaming_auto_approved_tool_preserves_value_when_preamble_text_coalesces( + chat_client_base: MockBaseChatClient, +) -> None: + """Preamble assistant text plus a tool call must not clobber the parsed structured value. + + Updates without ``message_id`` are coalesced by ``AgentResponse.from_updates``. If the + outer finalizer rebuilt from every yielded update, ``Calling echo…{"answer":"42"}`` + would fail to parse. The terminal inner response's value must be kept instead. + """ + from pydantic import BaseModel + + class Answer(BaseModel): + answer: str + + json_text = '{"answer": "42"}' + calls = 0 + + @tool(name="echo", approval_mode="always_require") + def echo(text: str) -> str: + nonlocal calls + calls += 1 + return text + + agent = Agent( + client=chat_client_base, + tools=[echo], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[lambda function_call: True])], + ) + session = AgentSession(session_id="structured-stream-auto-approve-preamble") + function_call = Content.from_function_call(call_id="call_echo", name="echo", arguments='{"text": "hi"}') + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate(role="assistant", contents=[Content.from_text("Calling echo…")]), + ChatResponseUpdate(role="assistant", contents=[function_call]), + ], + [ + ChatResponseUpdate( + role="assistant", + contents=[Content.from_text(json_text)], + finish_reason="stop", + ) + ], + ] + + stream = agent.run( + "Call echo and return an Answer.", + stream=True, + session=session, + options={"response_format": Answer}, + ) + updates = [update async for update in stream] + response = await stream.get_final_response() + + assert any("Calling echo" in (content.text or "") for update in updates for content in update.contents) + assert calls == 1 + assert isinstance(response.value, Answer) + assert response.value.answer == "42" + + +async def test_non_streaming_tool_approval_preserves_structured_value( + chat_client_base: MockBaseChatClient, +) -> None: + """Non-streaming ToolApprovalMiddleware already returns the inner AgentResponse.""" + from pydantic import BaseModel + + class Answer(BaseModel): + answer: str + + json_text = '{"answer": "42"}' + + @tool(name="echo", approval_mode="always_require") + def echo(text: str) -> str: + return text + + agent = Agent( + client=chat_client_base, + tools=[echo], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="structured-non-stream") + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=[json_text]))] + + response = await agent.run( + "return an Answer", + session=session, + options={"response_format": Answer}, + ) + + assert isinstance(response.value, Answer) + assert response.value.answer == "42" + + +async def test_streaming_tool_approval_defers_structured_parse_error_to_value_access( + chat_client_base: MockBaseChatClient, +) -> None: + """A structured-output parse failure must surface on ``value`` access, not while streaming. + + The outer finalizer resolves the terminal inner response's value eagerly so a coalesced + preamble cannot mask it (#7418). When that resolution fails, the value is left unset so + the outer response still parses lazily on ``value`` access — streaming iteration and + finalization succeed, matching the error timing of a middleware-free streaming run. + """ + from pydantic import BaseModel + + class Answer(BaseModel): + answer: str + + bad_text = "not json" + + agent = Agent( + client=chat_client_base, + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="structured-stream-parse-error") + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + role="assistant", + contents=[Content.from_text(bad_text)], + finish_reason="stop", + ) + ] + ] + + stream = agent.run("return an Answer", stream=True, session=session, options={"response_format": Answer}) + async for _ in stream: + pass # must not raise + response = await stream.get_final_response() + + assert response.text == bad_text + with pytest.raises(ValueError): + _ = response.value From e636a724279ab07217ae9817d7140301f5be2f20 Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Wed, 23 Sep 2026 08:03:15 +0000 Subject: [PATCH 26/42] Python: scope executor activity IDs to workflow runs (#8549) * Python: scope executor activity message IDs to workflow runs * Python: disambiguate run and executor activity ID components * Python: narrow activity snapshots in workflow regression tests --- .../agent_framework_ag_ui/_workflow_run.py | 2 +- .../ag-ui/tests/ag_ui/test_workflow_run.py | 75 ++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index 9a5126cc813..1f01c63c9f1 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -1479,7 +1479,7 @@ def _drain_open_blocks() -> list[BaseEvent]: executor_payload["data"] = make_json_safe(getattr(event, "data", None)) yield ActivitySnapshotEvent( - message_id=f"executor:{executor_id}" if executor_id else generate_event_id(), + message_id=f"{len(run_id)}:{run_id}:executor:{executor_id}" if executor_id else generate_event_id(), activity_type="executor", content=executor_payload, ) diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index 3b0778a6383..bcd2ddeda23 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -12,7 +12,7 @@ from typing import Any, cast import pytest -from ag_ui.core import EventType, RunFinishedEvent, RunStartedEvent, StateSnapshotEvent +from ag_ui.core import ActivitySnapshotEvent, EventType, RunFinishedEvent, RunStartedEvent, StateSnapshotEvent from agent_framework import ( Agent, AgentContext, @@ -3053,6 +3053,79 @@ async def _stream(): assert "RUN_FINISHED" in event_types +@pytest.mark.parametrize("terminal_type", ["executor_completed", "executor_failed"]) +async def test_executor_activity_ids_are_scoped_to_run(terminal_type: str) -> None: + """Progress replaces its own activity, without overwriting a previous run.""" + + class ActivityWorkflow: + def run(self, **kwargs: Any) -> AsyncIterator[Any]: + async def stream() -> AsyncIterator[Any]: + for executor_id in ("researcher", "writer"): + yield SimpleNamespace(type="executor_invoked", executor_id=executor_id, data=None) + yield SimpleNamespace(type=terminal_type, executor_id=executor_id, data=None) + + return stream() + + activities_by_run: list[list[Any]] = [] + for run_id in ("first-run", "second-run"): + events = [ + event + async for event in run_workflow_stream( + { + "thread_id": "same-thread", + "run_id": run_id, + "messages": [{"role": "user", "content": "go"}], + }, + cast(Any, ActivityWorkflow()), + ) + ] + activities = [event for event in events if isinstance(event, ActivitySnapshotEvent)] + assert len(activities) == 4 + assert activities[0].message_id == activities[1].message_id + assert activities[2].message_id == activities[3].message_id + assert activities[0].message_id != activities[2].message_id + assert [event.content["executor_id"] for event in activities] == [ + "researcher", + "researcher", + "writer", + "writer", + ] + activities_by_run.append(activities) + + assert {event.message_id for event in activities_by_run[0]}.isdisjoint( + event.message_id for event in activities_by_run[1] + ) + + +async def test_executor_activity_ids_do_not_collide_with_delimiters() -> None: + """Unrestricted run and executor IDs must not alias another pair.""" + + class ActivityWorkflow: + def __init__(self, executor_id: str) -> None: + self.executor_id = executor_id + + def run(self, **kwargs: Any) -> AsyncIterator[Any]: + async def stream() -> AsyncIterator[Any]: + yield SimpleNamespace(type="executor_invoked", executor_id=self.executor_id, data=None) + + return stream() + + message_ids: list[str] = [] + for run_id, executor_id in (("a", "b:executor:c"), ("a:executor:b", "c")): + events = [ + event + async for event in run_workflow_stream( + {"thread_id": "same-thread", "run_id": run_id, "messages": [{"role": "user", "content": "go"}]}, + cast(Any, ActivityWorkflow(executor_id)), + ) + ] + activities = [event for event in events if isinstance(event, ActivitySnapshotEvent)] + assert len(activities) == 1 + message_ids.append(activities[0].message_id) + + assert message_ids[0] != message_ids[1] + + async def test_workflow_run_executor_invoked_drains_text(): """executor_invoked should drain any open text message.""" From 23d5d25f13a6a3867f8b769f7d37d4c0d204ed0e Mon Sep 17 00:00:00 2001 From: Saquib Jawed <138011435+saquibjawedbit@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:14:19 +0000 Subject: [PATCH 27/42] Python: Keep null fields out of Azure DocumentDB membership filters (#8654) Portable in/not_in membership never matches a null field, but the DocumentDB filter compiler kept None in the $in operands behind an $exists guard, so Filter("x", "in", [None, "a"]) also returned documents whose field is null. Drop None operands like the MongoDB, Redis and Postgres connectors do. Fixes #8653 --- .../_vector_store.py | 11 ++++------- .../tests/azure_documentdb/test_vector_store.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py b/python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py index 3ad4d2520f2..fc9d94e768c 100644 --- a/python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py +++ b/python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py @@ -429,19 +429,16 @@ def _prepare_filter_condition(self, expression: FilterExpression) -> _Document: raise NotImplementedError( "Use contains, contains_any, or contains_all for Azure DocumentDB list fields." ) - operands = [_prepare_filter_operand(field, item) for item in value] + # Portable membership, unlike eq/ne, never matches a null field, even when None is an operand. + operands = [_prepare_filter_operand(field, item) for item in value if item is not None] operands = [item for item in operands if item is not _TYPE_MISMATCH] if not operands: if operator == "in": return self._false_filter() return {"$and": [{name: {"$exists": True}}, {name: {"$ne": None}}]} condition = {name: {"$in" if operator == "in" else "$nin": operands}} - if operator == "not_in" or None in operands: - guards: list[_Document] = [{name: {"$exists": True}}] - if operator == "not_in": - guards.append({name: {"$ne": None}}) - guards.append(condition) - return {"$and": guards} + if operator == "not_in": + return {"$and": [{name: {"$exists": True}}, {name: {"$ne": None}}, condition]} return condition if operator in {"contains", "contains_any", "contains_all"}: if field.type_ != "list": diff --git a/python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py b/python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py index 9d30ac8a0f1..77a3a6f21ee 100644 --- a/python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py +++ b/python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py @@ -320,6 +320,23 @@ def test_empty_membership_semantics(collection): } +def test_membership_never_matches_null_operands(collection): + assert collection._prepare_filter(Filter("category", "in", [None, "database"])) == { + "category": {"$in": ["database"]} + } + assert collection._prepare_filter(Filter("category", "in", [None])) == {"_id": {"$exists": False}} + assert collection._prepare_filter(Filter("category", "not_in", [None, "database"])) == { + "$and": [ + {"category": {"$exists": True}}, + {"category": {"$ne": None}}, + {"category": {"$nin": ["database"]}}, + ] + } + assert collection._prepare_filter(Filter("category", "not_in", [None])) == { + "$and": [{"category": {"$exists": True}}, {"category": {"$ne": None}}] + } + + def test_filter_type_mismatches_preserve_bool_number_distinction(collection): assert collection._prepare_filter(Filter("number", "eq", True)) == {"_id": {"$exists": False}} assert collection._prepare_filter(Filter("flag", "eq", 1)) == {"_id": {"$exists": False}} From 975493ac831dbc165285d9e5d386b5fa150be999 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:18:29 +0000 Subject: [PATCH 28/42] Python: Build(deps-dev): Bump the python-type-checkers group across 1 directory with 2 updates (#8650) * Build(deps-dev): Bump the python-type-checkers group across 1 directory with 2 updates Bumps the python-type-checkers group with 2 updates in the /python directory: [pyrefly](https://github.com/facebook/pyrefly) and [ty](https://github.com/astral-sh/ty). Updates `pyrefly` from 1.3.0 to 1.3.1 - [Release notes](https://github.com/facebook/pyrefly/releases) - [Commits](https://github.com/facebook/pyrefly/compare/1.3.0...1.3.1) Updates `ty` from 0.0.80 to 0.0.81 - [Release notes](https://github.com/astral-sh/ty/releases) - [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ty/compare/0.0.80...0.0.81) --- updated-dependencies: - dependency-name: pyrefly dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-type-checkers - dependency-name: ty dependency-version: 0.0.81 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-type-checkers ... Signed-off-by: dependabot[bot] * Regenerate python/uv.lock for pyrefly 1.3.1 and ty 0.0.81 Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --- python/pyproject.toml | 4 +-- python/uv.lock | 72 +++++++++++++++++++++---------------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 1c762ead681..35066954f0e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -39,8 +39,8 @@ dev = [ "pytest-retry==1.7.0", "mypy==2.3.1", "pyright==1.1.414", - "pyrefly==1.3.0", - "ty==0.0.80", + "pyrefly==1.3.1", + "ty==0.0.81", "zuban==0.9.3", "opentelemetry-sdk", #tasks diff --git a/python/uv.lock b/python/uv.lock index 65fe4e5f906..67edc89074c 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -163,7 +163,7 @@ dev = [ { name = "opentelemetry-sdk" }, { name = "poethepoet", specifier = "==0.48.0" }, { name = "prek", specifier = "==0.5.3" }, - { name = "pyrefly", specifier = "==1.3.0" }, + { name = "pyrefly", specifier = "==1.3.1" }, { name = "pyright", specifier = "==1.1.414" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, @@ -174,7 +174,7 @@ dev = [ { name = "rich", specifier = ">=13.7.1,<16.0.0" }, { name = "ruff", specifier = "==0.16.7" }, { name = "tomli", specifier = "==2.4.1" }, - { name = "ty", specifier = "==0.0.80" }, + { name = "ty", specifier = "==0.0.81" }, { name = "uv", specifier = "==0.12.16" }, { name = "zuban", specifier = "==0.9.3" }, ] @@ -5148,21 +5148,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/82/df301034ff7705b51ab212039958910434e1e9bd4765cde435538221fc35/pyrefly-1.3.0.tar.gz", hash = "sha256:e96a0bf3abdc41cf40f7d2ef274bb610f4e6e4b387b366aea102902839073388", size = 6678024, upload-time = "2026-09-11T00:41:37.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/98/47bf19b6caef77852c7e1c03d71ef48a5f452bf041503d883e0bca6fc4c8/pyrefly-1.3.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd6c61e4c307eaaf3cf25fb329c4e7c9ff0376c42d5900362be2fd3333578a69", size = 15088089, upload-time = "2026-09-11T00:41:11.046Z" }, - { url = "https://files.pythonhosted.org/packages/4c/77/454615aa5db04903c99ced321295e32242c4b188be1a08f40b617ed72984/pyrefly-1.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:14c56defaa804747f18e03d11b539e71b71c8251a2b9f58e73dea80e2f6adfd9", size = 14431422, upload-time = "2026-09-11T00:41:13.61Z" }, - { url = "https://files.pythonhosted.org/packages/01/f2/142e190b9432f802682b0b0794f71e2010e936de0541492cc43678d647af/pyrefly-1.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64285b1261681933f97cb11f3ba2e4445ff02a266707e4e2b7a279db0424c9f2", size = 14880659, upload-time = "2026-09-11T00:41:15.947Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2f/60598fe38f29f22d11d9f4b5c7fee1cbbb0d5b0cb7db6b373761d57f5f75/pyrefly-1.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46a90d6e48415dfa5d8d9c94ea5b9b61d7149dab87348952f2e000cf45145664", size = 16116073, upload-time = "2026-09-11T00:41:18.547Z" }, - { url = "https://files.pythonhosted.org/packages/c2/0a/33e49756fe3341dbb13789622cb8c9b6350d2710ceb47b8b0521f134ceea/pyrefly-1.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0cc33b35204b8e1b09396313024580d25a041de2f2f527e6496fc099282b967", size = 16049648, upload-time = "2026-09-11T00:41:21.255Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/be59429726af4c0c3585951a937232d957182364cd2a621f74f4298a176e/pyrefly-1.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e94ff692911596d257462320a2ef1435c93c20c80d631404961282f4cc22a6cc", size = 15439725, upload-time = "2026-09-11T00:41:23.572Z" }, - { url = "https://files.pythonhosted.org/packages/2b/73/bb834e88afea6c0de6960be2102c0cc1d817193c1efc3f6db9b450df191c/pyrefly-1.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5705bec5438aa4e3b65b0d2ea7613f6bdbdc57f8848bd4b7940bbab37f230d8c", size = 14900386, upload-time = "2026-09-11T00:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/c7/64/1adbc6ece9090bbd2e4ed1300918ab709166119426a21102639912ea65bb/pyrefly-1.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fe60cb6d7cdf6c9bb236c0a7efc4b9839cfe7ef834b07d637862f95a642699ef", size = 15478065, upload-time = "2026-09-11T00:41:28.049Z" }, - { url = "https://files.pythonhosted.org/packages/d0/5c/e1351611d2e205718394237381220ab70f0e21cb297db024eafead19acf9/pyrefly-1.3.0-py3-none-win32.whl", hash = "sha256:4fb4cd5e5007e99208e44f749edaf37a03c0a56c98da7422c2dd60fde0f294b0", size = 14130445, upload-time = "2026-09-11T00:41:30.401Z" }, - { url = "https://files.pythonhosted.org/packages/3c/92/1d78e2ab1f9f1055ea43164d336903c95e608bede06ed182dc4b4f2c5390/pyrefly-1.3.0-py3-none-win_amd64.whl", hash = "sha256:f809fa5b447012adb8861efeb8f6435146788a39d9251d79dc38731a5d241893", size = 15067598, upload-time = "2026-09-11T00:41:32.582Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6f/459f56dc345c32f8597acef13675e7e67679fb56bc3fd8f0a280eaeb8c02/pyrefly-1.3.0-py3-none-win_arm64.whl", hash = "sha256:3a3fb7c07dfb9b43d4205d8e85a83aaefc85377d0437dc8469c132836a8a7e5d", size = 14347403, upload-time = "2026-09-11T00:41:35.01Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" }, + { url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" }, + { url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/d31f8f0abf5b0610ec24183286ee8a1ee5b76c9aaaa91091399d7e1aaf60/pyrefly-1.3.1-py3-none-win32.whl", hash = "sha256:ea0d0349f1bc80fe8e00a7b3640e40db7486398b685f87b9bd5d2207063668f0", size = 14133005, upload-time = "2026-09-15T05:09:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/3f/23/1859f47f536314755d80c4ec2a76d2698435ae8b73604bcd1dfa57ecb39b/pyrefly-1.3.1-py3-none-win_amd64.whl", hash = "sha256:4c24e7d99df9237916c33be23714219a7c8eaf22bad2ac0c3e1d45adcf7df8fb", size = 15066557, upload-time = "2026-09-15T05:09:28.265Z" }, + { url = "https://files.pythonhosted.org/packages/92/2a/73e3721921c2bb2c950a9f8c362ee5e2367556b5d4416eb4f8f88503e66f/pyrefly-1.3.1-py3-none-win_arm64.whl", hash = "sha256:1ed85e2b957a78e93353125d890ce3668ce450e49b74025a15c87b6e8ec7f3d1", size = 14348209, upload-time = "2026-09-15T05:09:31.422Z" }, ] [[package]] @@ -6034,27 +6034,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.80" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, - { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, - { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, - { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, - { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, - { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, - { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, - { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, - { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, - { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, - { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, - { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, +version = "0.0.81" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b7/c9d736f48585f5a711ea47bb97a353d3771834f89481d747ea9687b74fa9/ty-0.0.81.tar.gz", hash = "sha256:ef721aa649bf41d665ba86e1ea726fd3feab6800e2c4887a062a704baf304ca8", size = 7225871, upload-time = "2026-09-15T02:05:29.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/70/649a5ec8fd6cc9dfa731c905523b19e677ad36dd9dcc4fcadfed8235507f/ty-0.0.81-py3-none-linux_armv6l.whl", hash = "sha256:8e9e3dd6edb1462633ddba2bf337d1c05023a5a13c2f315e445565c7ad43d247", size = 13679646, upload-time = "2026-09-15T02:04:51.436Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/3769b760918c3dde1bbaeee87422cd442fc550baadb5c8621e479b363282/ty-0.0.81-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:649c350873e4b3e937d856512ee57b8a345d80079cbead73a5308c2cdafe594c", size = 13211367, upload-time = "2026-09-15T02:04:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5f/b8fe3ab7eee87bbf1f5ff1b65c55526a3f31a9604630bc9e7d84199bbac5/ty-0.0.81-py3-none-macosx_11_0_arm64.whl", hash = "sha256:480c78706ba78239f901d68cf1ba53e98b84aa51e19b9241daa0fb62fdecd2e2", size = 13084312, upload-time = "2026-09-15T02:04:56.133Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/ce22278014f14ae8205d2f9622696d39c623a0c9e80056c258bb89bb8322/ty-0.0.81-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cdbfa4b634b1d21542eedc11e424dedcc4871c2128d0d46b55b0089d3220492", size = 13127965, upload-time = "2026-09-15T02:04:58.306Z" }, + { url = "https://files.pythonhosted.org/packages/82/d0/4e75c61f6a241a0804f25117af4f91a5aaa4a48408b57f8d4c34d8a3d91e/ty-0.0.81-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f7a34743a21669d4f3d5fda98c36189cb4d9dceee1415a8a42d7426d55995db", size = 13432523, upload-time = "2026-09-15T02:05:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/e0b6b783418515c8ba40c13d5e333da9e0576a7a69582fc2b5831bb7d913/ty-0.0.81-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:742eab849947bc10b4c09810d37bd743afe9502e114b7bcfd6389f0ae3f425aa", size = 14211082, upload-time = "2026-09-15T02:05:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/6b/af/85cb68d13ff617f2b213212d674e1266b933f2d051b63a1e5116e8ca0bfc/ty-0.0.81-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b4cef395eee7007449aa3fc3fc8dc6bc58fe7a8956d56db078569061741bf52", size = 14692204, upload-time = "2026-09-15T02:05:04.705Z" }, + { url = "https://files.pythonhosted.org/packages/24/c6/791891f3fa8fec91e9ec3e8c9d6774ab903297defe847a3e5d7e047360ad/ty-0.0.81-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80cf8c34973a1da2ec2a043efcba8f4c0a984d45210957a8ddd2c16f2b8f8e85", size = 14371010, upload-time = "2026-09-15T02:05:06.63Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b2/c9fba5b4ed7f63f26e83432fa2062ca04606b61dce7bbacc1238232af649/ty-0.0.81-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f37a8f66c444eea29f17adfe0664f42062520773f18031fd4913f28eb56d2966", size = 13758537, upload-time = "2026-09-15T02:05:08.722Z" }, + { url = "https://files.pythonhosted.org/packages/77/e6/09c41e96bbcbe53dd18c9a1a6dbd2e456b0d03b94440a4bd9bab55a4e19a/ty-0.0.81-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e8134cc03e84a27f409877b9b62053fd4aaac9c3ce0ce0fdace8b5ed58d78e04", size = 14280789, upload-time = "2026-09-15T02:05:10.719Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d1/87eb8e01fb17602cde5911cbadb9b11141a04f728dbda0a2a00243f337ca/ty-0.0.81-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb6f269c5f14a07047e05cbf3d06dd9a64a048050f736afcf744a180a272f034", size = 13169638, upload-time = "2026-09-15T02:05:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/94/3c/137ceb792858f408c48f20a066d12ee76108e5f28a13242720879c024172/ty-0.0.81-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:38355a5e6294ebd3cee616ed581d051963d61b293a1b3351c248747f73a45b74", size = 13449856, upload-time = "2026-09-15T02:05:15.505Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4a9b31fadfc80a2141f3862316eb31944843e01bc1f6533794291cce767e/ty-0.0.81-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59a35e128a9831423aee19db804aa8a1358ee3ce615b3fc0dec20d40bfd0d733", size = 13675303, upload-time = "2026-09-15T02:05:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/e3/16/dbcd2c10ab80b015a227110a4ec75314468b6720d800261098521df4eee9/ty-0.0.81-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f0f4219fbc068ea3414fa804c6bd57ef4229a11884ab9ca71cd8308072520aad", size = 14050298, upload-time = "2026-09-15T02:05:20.424Z" }, + { url = "https://files.pythonhosted.org/packages/95/ba/a70ced0c0c078ff09a3a80224d045c4267d07811dbaf125c749af5f72606/ty-0.0.81-py3-none-win32.whl", hash = "sha256:82ff952e64ba4c4da1c0c89723e326c768ae4f9ff20624dee04a51c3f4e4b96c", size = 12866650, upload-time = "2026-09-15T02:05:22.824Z" }, + { url = "https://files.pythonhosted.org/packages/3f/51/4b79453df93dfb9edecb1bd6cb04e772277d8c7260693165358a033b6ed1/ty-0.0.81-py3-none-win_amd64.whl", hash = "sha256:ef82788744da0b7f2a598e4e9776f9c5998df4eef7e2750ae3a5537e7c734b69", size = 13572976, upload-time = "2026-09-15T02:05:24.907Z" }, + { url = "https://files.pythonhosted.org/packages/0a/56/09594d55a0c49543eeac149c0dbc80b9b04409cc26ca48f1c1a9595c0916/ty-0.0.81-py3-none-win_arm64.whl", hash = "sha256:bdb1563990c5d3abfe5a09e7512733f302773ede6a5d3e4e5c6ad46393d8df4d", size = 13364408, upload-time = "2026-09-15T02:05:27.247Z" }, ] [[package]] From f42ff0147d03a6b7ed5d9e2731fc54aca89c20e5 Mon Sep 17 00:00:00 2001 From: Aditya-XR Date: Wed, 23 Sep 2026 08:19:54 +0000 Subject: [PATCH 29/42] fix(python): treat Any as compatible with any target type (#8660) is_type_compatible() only handled Any as a target, so an executor declaring WorkflowContext[Any] could not be connected to a typed executor and workflow build failed with TypeCompatibilityError. --- .../core/agent_framework/_workflows/_typing_utils.py | 5 +++++ .../packages/core/tests/workflow/test_typing_utils.py | 4 ++++ python/packages/core/tests/workflow/test_validation.py | 10 ++++++++++ 3 files changed, 19 insertions(+) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 251441144fb..6496716f1ec 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -471,6 +471,11 @@ def is_type_compatible(source_type: type | UnionType | Any, target_type: type | if target_type is Any: return True + # Case 0b: source_type is Any - the value can be of any type, so it is compatible with every + # target, the same way static type checkers treat Any. + if source_type is Any: + return True + # Case 1: exact type match if source_type == target_type: return True diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index df08120282c..805ad197107 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -425,6 +425,10 @@ def test_type_compatibility_basic() -> None: # Any compatibility assert is_type_compatible(str, Any) assert is_type_compatible(list[int], Any) + assert is_type_compatible(Any, str) + assert is_type_compatible(Any, list[int]) + assert is_type_compatible(Any, Union[str, int]) + assert is_type_compatible(list[Any], list[str]) # Subclass compatibility class Animal: diff --git a/python/packages/core/tests/workflow/test_validation.py b/python/packages/core/tests/workflow/test_validation.py index c2f85614c85..5798c5ec778 100644 --- a/python/packages/core/tests/workflow/test_validation.py +++ b/python/packages/core/tests/workflow/test_validation.py @@ -121,6 +121,16 @@ def test_type_compatibility_with_any_type_passes(): assert workflow is not None +def test_type_compatibility_from_any_source_passes(): + """An executor declaring WorkflowContext[Any] can send to a typed executor.""" + any_executor = AnyExecutor(id="any_executor") + string_executor = StringExecutor(id="string_executor") + + workflow = WorkflowBuilder(start_executor=any_executor).add_edge(any_executor, string_executor).build() + + assert workflow is not None + + def test_type_compatibility_with_no_output_types(): no_output_executor = NoOutputTypesExecutor(id="no_output") string_executor = StringExecutor(id="string_executor") From 76ccf3c44cee748aa582c446a7218f91289f2aac Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 23 Sep 2026 12:59:35 +0000 Subject: [PATCH 30/42] fix(dotnet): forward declarative Azure agent version (#8658) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ed91283-cb21-49a7-98f4-b1c088d0b6b6 --- .../Extensions/AgentProviderExtensions.cs | 3 +- .../Kit/AgentExecutor.cs | 2 +- .../ObjectModel/InvokeAzureAgentExecutor.cs | 9 +++- .../Extensions/AgentProviderExtensionsTest.cs | 1 + .../InvokeAzureAgentExecutorTest.cs | 46 +++++++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs index bd9d590987e..816c8abf646 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -16,13 +16,14 @@ public static async ValueTask InvokeAgentAsync( string executorId, IWorkflowContext context, string agentName, + string? agentVersion, string? conversationId, bool autoSend, IEnumerable? inputMessages = null, IDictionary? inputArguments = null, CancellationToken cancellationToken = default) { - IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken); + IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, agentVersion, conversationId, inputMessages, inputArguments, cancellationToken); // Foundry managed workflows treat responses produced on the workflow conversation // as workflow output even when autoSend is explicitly false. Preserve that direct-run diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs index aea534a258b..692787ab936 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs @@ -33,5 +33,5 @@ protected ValueTask InvokeAgentAsync( bool autoSend, IEnumerable? inputMessages = null, CancellationToken cancellationToken = default) - => agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, inputArguments: null, cancellationToken); + => agentProvider.InvokeAgentAsync(this.Id, context, agentName, agentVersion: null, conversationId, autoSend, inputMessages, inputArguments: null, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 2b0378f9998..6d152584530 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading; @@ -67,9 +68,10 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable? inputParameters = this.GetStructuredInputs(); - AgentResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, inputParameters, cancellationToken).ConfigureAwait(false); + AgentResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, agentVersion, conversationId, autoSend, messages, inputParameters, cancellationToken).ConfigureAwait(false); ChatMessage[] actionableMessages = FilterActionableContent(agentResponse).ToArray(); if (actionableMessages.Length > 0) @@ -202,6 +204,11 @@ private string GetAgentName() => this.AgentUsage.Name, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}.{nameof(this.Model.Agent.Name)}")).Value; + private string? GetAgentVersion() => + this.AgentUsage.Version is null + ? null + : this.Evaluator.GetValue(this.AgentUsage.Version).Value.ToString(CultureInfo.InvariantCulture); + private bool GetAutoSendValue() { // AzureAgentOutput.AutoSend is never null — it returns a literal-false default diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs index 238fd412245..5fd06d0f4e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/AgentProviderExtensionsTest.cs @@ -101,6 +101,7 @@ await mockProvider.Object.InvokeAgentAsync( actionId, context, AgentName, + agentVersion: null, conversationId, autoSend, cancellationToken: cancellationToken).ConfigureAwait(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs index b4f80f97c90..c861d77eace 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs @@ -72,6 +72,43 @@ public async Task MultipleNamedArgumentsAreAllBoundAsync() Assert.Equal("beta", provider.CapturedArguments!["b"]); } + [Fact] + public async Task DeclaredAgentVersionIsForwardedAsync() + { + // Arrange + this.State.InitializeSystem(); + CapturingAgentProvider provider = new("acknowledged"); + InvokeAzureAgent model = + this.CreateModel( + displayName: nameof(DeclaredAgentVersionIsForwardedAsync), + agentName: "BrainVersioned", + agentVersion: 7); + + // Act + await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false); + + // Assert + Assert.Equal("7", provider.CapturedAgentVersion); + } + + [Fact] + public async Task OmittedAgentVersionUsesProviderDefaultAsync() + { + // Arrange + this.State.InitializeSystem(); + CapturingAgentProvider provider = new("acknowledged"); + InvokeAzureAgent model = + this.CreateModel( + displayName: nameof(OmittedAgentVersionUsesProviderDefaultAsync), + agentName: "BrainLatest"); + + // Act + await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false); + + // Assert + Assert.Null(provider.CapturedAgentVersion); + } + [Fact] public async Task RecordValuedArgumentIsBoundAsRecordAsync() { @@ -290,6 +327,7 @@ public async Task NonObjectJsonOutputWithoutResponseObjectDoesNotThrowAsync() private InvokeAzureAgent CreateModel( string displayName, string agentName, + long? agentVersion = null, IReadOnlyList<(string Key, ValueExpression Value)>? arguments = null, string? responseObjectVariable = null) { @@ -305,6 +343,11 @@ private InvokeAzureAgent CreateModel( }, }; + if (agentVersion is not null) + { + builder.Agent.Version = new IntExpression.Builder(IntExpression.Literal(agentVersion.Value)); + } + if (arguments is not null) { AzureAgentInput.Builder inputBuilder = new(); @@ -358,6 +401,8 @@ private InvokeAzureAgent CreateAutoSendModel(string displayName, bool? autoSend) /// private sealed class CapturingAgentProvider(string responseText) : ResponseAgentProvider { + public string? CapturedAgentVersion { get; private set; } + public IDictionary? CapturedArguments { get; private set; } public override IAsyncEnumerable InvokeAgentAsync( @@ -368,6 +413,7 @@ public override IAsyncEnumerable InvokeAgentAsync( IDictionary? inputArguments, CancellationToken cancellationToken = default) { + this.CapturedAgentVersion = agentVersion; this.CapturedArguments = inputArguments; return YieldAsync(responseText); } From a638ce136ed6e0457877373b0e377e1f1d33277d Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 23 Sep 2026 13:20:30 +0000 Subject: [PATCH 31/42] .NET: Fix forwarded request-port type validation (#8657) * fix(dotnet): validate forwarded request port types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ed91283-cb21-49a7-98f4-b1c088d0b6b6 * fix(dotnet): preserve request type assignability Validate wrapped request forwarding against the original declared CLR type before deserializing the payload, preserving derived-to-base forwarding while rejecting unrelated types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 560f0ccd-0091-485e-87fc-d55bf3f2bb45 * chore: Potential fix for pull request finding 'Missed opportunity to use Where' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Fix request port duplicate type resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e0df64-ceeb-4096-8811-964b3f2dddbe --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Copilot-Session: 7ed91283-cb21-49a7-98f4-b1c088d0b6b6 Copilot-Session: 560f0ccd-0091-485e-87fc-d55bf3f2bb45 Copilot-Session: 09e0df64-ceeb-4096-8811-964b3f2dddbe --- .../Specialized/RequestInfoExecutor.cs | 30 +++- .../RequestInfoExecutorTests.cs | 133 ++++++++++++++++++ .../TestJsonContext.cs | 4 + 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RequestInfoExecutorTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs index 52dae66b881..bcc8616dfb0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Shared.Diagnostics; @@ -88,7 +91,13 @@ public async ValueTask HandleAsync(ExternalRequest message, IWo Debug.Assert(this._allowWrapped); Throw.IfNull(message); - if (!message.Data.IsType(this.Port.Request, out var requestData)) + Type? originalRequestType = ResolveType(message.PortInfo.RequestType, this.Port.Request); + if (originalRequestType is null || !this.Port.Request.IsAssignableFrom(originalRequestType)) + { + throw new InvalidOperationException($"Request type {this.Port.Request} is not valid for original request, whose request type is {message.PortInfo.RequestType}"); + } + + if (!message.Data.IsType(originalRequestType, out var requestData)) { throw new InvalidOperationException($"Message type {message.Data.TypeId} could not be interpreted as a value of Request Type {this.Port.Request}"); } @@ -107,6 +116,25 @@ public async ValueTask HandleAsync(ExternalRequest message, IWo return request; } + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Request types are preserved by their workflow port and serialization registrations.")] + internal static Type? ResolveType(TypeId typeId, Type assignableToType) + { + Throw.IfNull(typeId); + Throw.IfNull(assignableToType); + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => string.Equals(assembly.GetName().Name, typeId.SimpleAssemblyName, StringComparison.Ordinal))) + { + Type? resolvedType = assembly.GetType(typeId.NormalizedTypeName, throwOnError: false); + if (resolvedType is not null && assignableToType.IsAssignableFrom(resolvedType)) + { + return resolvedType; + } + } + + return null; + } + public async ValueTask HandleAsync(object message, IWorkflowContext context, CancellationToken cancellationToken = default) { Throw.IfNull(message); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RequestInfoExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RequestInfoExecutorTests.cs new file mode 100644 index 00000000000..8370bce2f30 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RequestInfoExecutorTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using System.Reflection.Emit; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Agents.AI.Workflows.Specialized; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal sealed record RequestPortSourceRequest(string Value); + +internal sealed record RequestPortTargetRequest(string Value); + +internal record RequestPortBaseRequest(string Value); + +internal sealed record RequestPortDerivedRequest(string Value) : RequestPortBaseRequest(Value); + +public abstract class RequestPortDuplicateRequestBase +{ +} + +public class RequestInfoExecutorTests +{ + [Fact] + public async Task HandleAsync_RejectsForwardingToRequestPortWithDifferentRequestTypeAsync() + { + // Arrange + RequestPort sourcePort = RequestPort.Create("source"); + RequestPort targetPort = RequestPort.Create("target"); + ExternalRequest originalRequest = ExternalRequest.Create(sourcePort, new RequestPortSourceRequest("value")); + ExternalRequest serializedRequest = JsonSerializationTests.RunJsonRoundtrip(originalRequest, TestJsonContext.Default.Options); + RequestInfoExecutor executor = new(targetPort); + TestRunContext runContext = new(); + runContext.ConfigureExecutor(executor); + executor.AttachRequestSink(runContext); + + Assert.True(serializedRequest.Data.IsDelayedDeserialization); + Assert.True(serializedRequest.Data.TypeId.IsMatch()); + + // Act + async Task ActAsync() => await executor.HandleAsync(serializedRequest, runContext.BindWorkflowContext(executor.Id)); + + // Assert + InvalidOperationException exception = await Assert.ThrowsAsync(ActAsync); + Assert.Contains(nameof(RequestPortTargetRequest), exception.Message); + Assert.Contains(nameof(RequestPortSourceRequest), exception.Message); + Assert.Empty(runContext.ExternalRequests); + } + + [Fact] + public async Task HandleAsync_ForwardsDerivedRequestToRequestPortWithBaseRequestTypeAsync() + { + // Arrange + RequestPort sourcePort = RequestPort.Create("source"); + RequestPort targetPort = RequestPort.Create("target"); + ExternalRequest originalRequest = ExternalRequest.Create(sourcePort, new RequestPortDerivedRequest("value")); + ExternalRequest serializedRequest = JsonSerializationTests.RunJsonRoundtrip(originalRequest, TestJsonContext.Default.Options); + RequestInfoExecutor executor = new(targetPort); + TestRunContext runContext = new(); + runContext.ConfigureExecutor(executor); + executor.AttachRequestSink(runContext); + + // Act + ExternalRequest forwardedRequest = + await executor.HandleAsync(serializedRequest, runContext.BindWorkflowContext(executor.Id)); + + // Assert + Assert.Equal(targetPort.ToPortInfo(), forwardedRequest.PortInfo); + Assert.Equal(originalRequest.RequestId, forwardedRequest.RequestId); + RequestPortDerivedRequest? forwardedData = forwardedRequest.Data.As(); + Assert.NotNull(forwardedData); + Assert.Equal("value", forwardedData.Value); + Assert.Same(forwardedRequest, Assert.Single(runContext.ExternalRequests)); + } + + [Fact] + public async Task HandleAsync_ForwardsToRequestPortWithMatchingRequestTypeAsync() + { + // Arrange + RequestPort sourcePort = RequestPort.Create("source"); + RequestPort targetPort = RequestPort.Create("target"); + ExternalRequest originalRequest = ExternalRequest.Create(sourcePort, new RequestPortSourceRequest("value")); + ExternalRequest serializedRequest = JsonSerializationTests.RunJsonRoundtrip(originalRequest, TestJsonContext.Default.Options); + RequestInfoExecutor executor = new(targetPort); + TestRunContext runContext = new(); + runContext.ConfigureExecutor(executor); + executor.AttachRequestSink(runContext); + + // Act + ExternalRequest forwardedRequest = + await executor.HandleAsync(serializedRequest, runContext.BindWorkflowContext(executor.Id)); + + // Assert + Assert.Equal(targetPort.ToPortInfo(), forwardedRequest.PortInfo); + Assert.Equal(originalRequest.RequestId, forwardedRequest.RequestId); + Assert.Equal(new RequestPortSourceRequest("value"), forwardedRequest.Data.As()); + Assert.Same(forwardedRequest, Assert.Single(runContext.ExternalRequests)); + } + + [Fact] + public void ResolveType_SkipsIncompatibleSameNameTypeLoadedBeforeCompatibleType() + { + // Arrange + const string AssemblyName = "RequestPortDuplicateTypes"; + const string TypeName = "Duplicate.Request"; + Type incompatibleType = DefineDuplicateRequestType(AssemblyName, TypeName); + Type compatibleType = DefineDuplicateRequestType(AssemblyName, TypeName, typeof(RequestPortDuplicateRequestBase)); + TypeId typeId = new($"{AssemblyName}, Version=1.0.0.0", TypeName); + + // Act + Type? resolvedType = RequestInfoExecutor.ResolveType(typeId, typeof(RequestPortDuplicateRequestBase)); + + // Assert + Assert.NotNull(resolvedType); + Assert.Same(compatibleType, resolvedType); + Assert.False(typeof(RequestPortDuplicateRequestBase).IsAssignableFrom(incompatibleType)); + } + + private static Type DefineDuplicateRequestType(string assemblyName, string typeName, Type? baseType = null) + { + AssemblyBuilder assemblyBuilder = + AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(assemblyName), AssemblyBuilderAccess.Run); + ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName); + TypeBuilder typeBuilder = + moduleBuilder.DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class, baseType); + + TypeInfo? typeInfo = typeBuilder.CreateTypeInfo(); + Assert.NotNull(typeInfo); + return typeInfo.AsType(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs index db8e8eda3a8..c6f0efd1321 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestJsonContext.cs @@ -8,5 +8,9 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; // Checkpointing Types [JsonSerializable(typeof(TestJsonSerializable))] [JsonSerializable(typeof(TestExternalRequestEnvelope))] +[JsonSerializable(typeof(RequestPortSourceRequest))] +[JsonSerializable(typeof(RequestPortTargetRequest))] +[JsonSerializable(typeof(RequestPortBaseRequest))] +[JsonSerializable(typeof(RequestPortDerivedRequest))] [ExcludeFromCodeCoverage] internal sealed partial class TestJsonContext : JsonSerializerContext; From 0bbb7245d408b6b7209eca58609ca53a52a66cec Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 23 Sep 2026 13:37:45 +0000 Subject: [PATCH 32/42] .NET: [BREAKING] fix: use allow list for configuration keys (#8200) * fix: use allow list for configuration keys Signed-off-by: Vincent Biret * Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * chore: formatting * Potential fix for pull request finding 'Missed opportunity to use Select' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Address declarative workflow review feedback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * .NET: address workflow sensitivity review comments Persist workflow sensitivity metadata for checkpoint restore, propagate sensitive default question values, and derive expression sensitivity from Power Fx syntax rather than raw text scanning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: fix ChatClient factory options validation Validate factory options before the base constructor initializer uses them so null handling remains explicit without nullable dereferences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 668ac22f-5312-4df7-b470-55ca8535862d * chore: formatting * fix review feedback on declarative sensitivity APIs Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * fix declarative review feedback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * fix: context forwarding * Fix declarative sensitivity review comments Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Fix declarative review feedback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Merge main and resolve conflicts Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Revert "Merge main and resolve conflicts" This reverts commit 8a23310d3f764d0ebd6a8dc2e492ae34d6496a50. Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Address declarative review feedback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Update dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs * Update dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs * Remove unusable Power Fx Set opt-in Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Update dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs * fix: pass the env var allow list down the prompt * fix: missing sensitivity for state * tests: adds additional tests to cover edge scenarios * chore: Change ReadStateWithSensitivityAsync to use PortableValue Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * tests: fixes build failure because of copilot suggestion * fix: pass the sensitivity through the foreach loop * tests: fixes build failure for netfx * tests: fixes bound validation * chore: moves new class to dedicated file * tests: fixes netfx build issues * fix: adds missing netfx conditional compilation directive * chore: removes noisy additional options * chore: restore missing defensive programming Signed-off-by: Vincent Biret * chore: refactors duplicated constructor Signed-off-by: Vincent Biret * chore: Initialize Sensitivities dictionary with case-insensitive comparer Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: null reference exception Signed-off-by: Vincent Biret * chore: formatting Signed-off-by: Vincent Biret * tests: adds missing test to cover the fallback scenario Signed-off-by: Vincent Biret * fix: removes back compat allow list Signed-off-by: Vincent Biret * tests: updates assertions to account for non back compat behaviour Signed-off-by: Vincent Biret * tests: inspect the inner exception to avoid debounce differences between net and netfx Signed-off-by: Vincent Biret * chore: makes comparison case sensitive * chore: makes comparison case sensitive * fix: avoid calling sync over async to potentially cause deadlocks Signed-off-by: Vincent Biret * fix: adds compatibility deprecated method Signed-off-by: Vincent Biret * fix: binary compatibility Signed-off-by: Vincent Biret * fix: missing constructor * chore: removes extraneous constructor Signed-off-by: Vincent Biret * tests: aligns with constructor changes Signed-off-by: Vincent Biret * chore: removes outdated methods Signed-off-by: Vincent Biret * chore: formatting --------- Signed-off-by: Vincent Biret Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: 668ac22f-5312-4df7-b470-55ca8535862d --- .../InvokeFoundryToolboxMcp/Program.cs | 1 + .../AgentBotElementYaml.cs | 35 ++- .../ChatClientPromptAgentFactory.cs | 44 +++- .../Extensions/BoolExpressionExtensions.cs | 9 +- .../Extensions/IntExpressionExtensions.cs | 9 +- .../Extensions/NumberExpressionExtensions.cs | 9 +- .../Extensions/PromptAgentExtensions.cs | 21 +- .../Extensions/StringExpressionExtensions.cs | 9 +- .../Extensions/YamlAgentFactoryExtensions.cs | 2 +- .../PromptAgentFactory.cs | 77 ++++++- .../DeclarativeWorkflowBuilder.cs | 6 +- .../DeclarativeWorkflowOptions.cs | 11 + .../Extensions/IWorkflowContextExtensions.cs | 25 +- .../Interpreter/DeclarativeActionExecutor.cs | 24 +- .../Interpreter/DeclarativeWorkflowContext.cs | 61 +++-- .../Kit/IWorkflowContextExtensions.cs | 175 +++++++++++++- .../Kit/RootExecutor.cs | 21 +- .../AddConversationMessageExecutor.cs | 17 +- .../CopyConversationMessagesExecutor.cs | 5 + .../ObjectModel/EditTableExecutor.cs | 33 ++- .../ObjectModel/EditTableV2Executor.cs | 27 ++- .../ObjectModel/ForeachExecutor.cs | 15 +- .../ObjectModel/InvokeAzureAgentExecutor.cs | 5 + .../ObjectModel/ParseValueExecutor.cs | 2 +- .../ObjectModel/QuestionExecutor.cs | 15 +- .../ObjectModel/SendActivityExecutor.cs | 10 +- .../SetMultipleVariablesExecutor.cs | 2 +- .../ObjectModel/SetTextVariableExecutor.cs | 6 +- .../ObjectModel/SetVariableExecutor.cs | 2 +- .../PowerFx/RecalcEngineFactory.cs | 1 - .../PowerFx/WorkflowDiagnostics.cs | 31 ++- .../PowerFx/WorkflowExpressionEngine.cs | 186 ++++++++++++++- .../PowerFx/WorkflowFormulaState.cs | 66 +++++- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 12 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 12 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 12 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 12 + .../netstandard2.0/PublicAPI.Unshipped.txt | 12 + .../InProc/InProcessRunner.cs | 2 +- .../Workflows/Execution/WorkflowFactory.cs | 3 + .../AgentBotElementYamlTests.cs | 25 +- .../ChatClient/ChatClientAgentFactoryTests.cs | 190 +++++++++++++++ .../DeclarativeWorkflowTest.cs | 204 +++++++++++++++++ .../Kit/IWorkflowContextExtensionsTests.cs | 216 ++++++++++++++++++ .../Kit/RootExecutorTests.cs | 60 +++++ .../AddConversationMessageExecutorTest.cs | 62 ++++- .../CopyConversationMessagesExecutorTest.cs | 26 +++ .../InvokeAzureAgentExecutorTest.cs | 36 +++ .../ObjectModel/QuestionExecutorTest.cs | 28 ++- .../ObjectModel/SendActivityExecutorTest.cs | 20 ++ .../PowerFx/RecalcEngineFactoryTests.cs | 8 +- .../PowerFx/WorkflowExpressionEngineTests.cs | 74 ++++++ .../PowerFx/WorkflowFormulaStateTests.cs | 23 ++ .../Workflows/Condition.cs | 12 +- .../Workflows/ConditionElse.cs | 8 +- .../Workflows/EditTable.cs | 4 +- .../Workflows/EditTableV2.cs | 4 +- .../Workflows/LoopBreak.cs | 61 ++++- .../Workflows/LoopContinue.cs | 61 ++++- .../Workflows/LoopEach.cs | 61 ++++- .../Workflows/ParseValue.cs | 4 +- .../Workflows/SendActivity.cs | 4 +- .../Workflows/SetTextVariable.cs | 6 +- .../Workflows/SetVariable.cs | 4 +- .../CheckpointResumeTests.cs | 59 +++++ .../05_Simple_Workflow_Checkpointing.cs | 6 +- 66 files changed, 2075 insertions(+), 218 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/RootExecutorTests.cs diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs index 7c3ecbc7e96..d6d9396fd4c 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs @@ -100,6 +100,7 @@ public static async Task Main(string[] args) WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint) { Configuration = workflowConfiguration, + AllowedEnvironmentVariables = [ToolboxMcpServerUrlSetting, DocsServerLabelSetting, WebSearchToolNameSetting], McpToolHandler = mcpToolHandler }; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs index 89eacdf8fa9..4e62030ccd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Microsoft.Agents.ObjectModel; using Microsoft.Agents.ObjectModel.Abstractions; +using Microsoft.Agents.ObjectModel.Analysis; +using Microsoft.Agents.ObjectModel.PowerFx; using Microsoft.Agents.ObjectModel.Yaml; using Microsoft.Extensions.Configuration; using Microsoft.Shared.Diagnostics; @@ -22,8 +25,9 @@ internal static class AgentBotElementYaml /// /// YAML representation of the to use to create the prompt function. /// Optional instance which provides environment variables to the template. + /// Configuration keys that may be exposed when the YAML references them through Env. [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] - public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null) + public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null, IEnumerable? allowedConfigurationVariables = null) { Throw.IfNullOrEmpty(text); @@ -35,7 +39,7 @@ public static GptComponentMetadata FromYaml(string text, IConfiguration? configu throw new InvalidDataException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(GptComponentMetadata)}."); } - var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration); + var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration, allowedConfigurationVariables); return botDefinition.Descendants().OfType().First(); } @@ -52,7 +56,7 @@ private sealed class AgentFeatureConfiguration : IFeatureConfiguration public bool IsTenantFeatureEnabled(string featureName, bool defaultValue) => defaultValue; } - public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null) + public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null, IEnumerable? allowedConfigurationVariables = null) { var botBuilder = new BotDefinition.Builder @@ -67,19 +71,26 @@ public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata ele } }; - if (configuration is not null) + if (configuration is not null && allowedConfigurationVariables is not null) { - foreach (var kvp in configuration.AsEnumerable().Where(kvp => kvp.Value is not null)) + HashSet allowedVariables = new(allowedConfigurationVariables, StringComparer.OrdinalIgnoreCase); + foreach (string variableName in GetReferencedEnvironmentVariableNames(element).Where(allowedVariables.Contains)) { + string? configurationValue = configuration[variableName]; + if (configurationValue is null) + { + continue; + } + botBuilder.EnvironmentVariables.Add(new EnvironmentVariableDefinition.Builder() { - SchemaName = kvp.Key, + SchemaName = variableName, Id = Guid.NewGuid(), - DisplayName = kvp.Key, + DisplayName = variableName, ValueComponent = new EnvironmentVariableValue.Builder() { Id = Guid.NewGuid(), - Value = kvp.Value!, + Value = configurationValue, }, }); } @@ -87,5 +98,13 @@ public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata ele return botBuilder.Build(); } + + internal static ISet GetReferencedEnvironmentVariableNames(GptComponentMetadata element) + { + var botDefinition = WrapPromptAgentWithBot(element); + SemanticModel semanticModel = botDefinition.GetSemanticModel(new PowerFxExpressionChecker(new AgentFeatureConfiguration()), new AgentFeatureConfiguration()); + + return semanticModel.GetAllEnvironmentVariablesReferencedInTheBot(); + } #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs index 28f0c47fbb6..e2af721adee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs @@ -20,36 +20,70 @@ public sealed class ChatClientPromptAgentFactory : PromptAgentFactory /// /// Creates a new instance of the class. /// - public ChatClientPromptAgentFactory(IChatClient chatClient, IList? functions = null, RecalcEngine? engine = null, IConfiguration? configuration = null, ILoggerFactory? loggerFactory = null) : base(engine, configuration) + /// The chat client used by created agents. + /// Optional functions exposed as tools to created agents. + /// Optional Power Fx engine used to evaluate declarative expressions. + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + /// Optional logger factory used by created agents. + public ChatClientPromptAgentFactory( + IChatClient chatClient, + IList? functions = null, + RecalcEngine? engine = null, + IConfiguration? configuration = null, + ILoggerFactory? loggerFactory = null) + : this(chatClient, functions, engine, configuration, loggerFactory, null) { - Throw.IfNull(chatClient); + // BINARY COMPAT CONSTRUCTOR + } + /// + /// Creates a new instance of the class. + /// + /// The chat client used by created agents. + /// Optional functions exposed as tools to created agents. + /// Optional Power Fx engine used to evaluate declarative expressions. + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + /// Optional logger factory used by created agents. + /// Optional explicitly allowed environment variables referenced by the agent definition. + public ChatClientPromptAgentFactory( + IChatClient chatClient, + IList? functions, + RecalcEngine? engine, + IConfiguration? configuration, + ILoggerFactory? loggerFactory, + IEnumerable? allowedConfigurationVariables) + : base(engine, configuration, allowedConfigurationVariables) + { + Throw.IfNull(chatClient); this._chatClient = chatClient; this._functions = functions; this._loggerFactory = loggerFactory; } /// - public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + public override async Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) { Throw.IfNull(promptAgent); + this.InitializeConfigurationVariables(promptAgent); + var options = new ChatClientAgentOptions() { Name = promptAgent.Name, Description = promptAgent.Description, - ChatOptions = promptAgent.GetChatOptions(this.Engine, this._functions), + ChatOptions = await promptAgent.GetChatOptionsAsync(this.Engine, this._functions, cancellationToken: cancellationToken).ConfigureAwait(false), }; var agent = new ChatClientAgent(this._chatClient, options, this._loggerFactory); Declarative.FeatureUsageMarker.MarkUsed(); - return Task.FromResult(agent); + return agent; } #region private private readonly IChatClient _chatClient; private readonly IList? _functions; private readonly ILoggerFactory? _loggerFactory; + #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs index 9b12ea19fdd..a114f28ddcd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -15,8 +17,9 @@ internal static class BoolExpressionExtensions /// /// Expression to evaluate. /// Recalc engine to use for evaluation. + /// Cancellation token to observe while evaluating the expression. /// The evaluated boolean value, or null if the expression is null or cannot be evaluated. - internal static bool? Eval(this BoolExpression? expression, RecalcEngine? engine) + internal static async Task EvalAsync(this BoolExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -35,11 +38,11 @@ internal static class BoolExpressionExtensions if (expression.IsExpression) { - return engine.Eval(expression.ExpressionText!).AsBoolean(); + return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsBoolean(); } else if (expression.IsVariableReference) { - var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false); if (formulaValue is BooleanValue booleanValue) { return booleanValue.Value; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs index dbc6ff4dda4..3d24cb19fbe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -16,8 +18,9 @@ internal static class IntExpressionExtensions /// /// Expression to evaluate. /// Recalc engine to use for evaluation. + /// Cancellation token to observe while evaluating the expression. /// The evaluated integer value, or null if the expression is null or cannot be evaluated. - internal static long? Eval(this IntExpression? expression, RecalcEngine? engine) + internal static async Task EvalAsync(this IntExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -36,11 +39,11 @@ internal static class IntExpressionExtensions if (expression.IsExpression) { - return (long)engine.Eval(expression.ExpressionText!).AsDouble(); + return (long)(await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsDouble(); } else if (expression.IsVariableReference) { - var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false); if (formulaValue is NumberValue numberValue) { return (long)numberValue.Value; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs index b4f59a015a1..aa6710025b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -16,8 +18,9 @@ internal static class NumberExpressionExtensions /// /// Expression to evaluate. /// Recalc engine to use for evaluation. + /// Cancellation token to observe while evaluating the expression. /// The evaluated number value, or null if the expression is null or cannot be evaluated. - internal static double? Eval(this NumberExpression? expression, RecalcEngine? engine) + internal static async Task EvalAsync(this NumberExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -36,11 +39,11 @@ internal static class NumberExpressionExtensions if (expression.IsExpression) { - return engine.Eval(expression.ExpressionText!).AsDouble(); + return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken).ConfigureAwait(false)).AsDouble(); } else if (expression.IsVariableReference) { - var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + var formulaValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken).ConfigureAwait(false); if (formulaValue is NumberValue numberValue) { return numberValue.Value; diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs index 0da3f18f85a..ea02a7f1910 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.PowerFx; using Microsoft.Shared.Diagnostics; @@ -19,7 +21,8 @@ public static class PromptAgentExtensions /// Instance of /// Instance of /// Instance of - public static ChatOptions? GetChatOptions(this GptComponentMetadata promptAgent, RecalcEngine? engine, IList? functions) + /// Cancellation token to observe while retrieving chat options. + public static async Task GetChatOptionsAsync(this GptComponentMetadata promptAgent, RecalcEngine? engine, IList? functions, CancellationToken cancellationToken = default) { Throw.IfNull(promptAgent); @@ -36,17 +39,17 @@ public static class PromptAgentExtensions return new ChatOptions() { Instructions = promptAgent.Instructions?.ToTemplateString(), - Temperature = (float?)modelOptions?.Temperature?.Eval(engine), - MaxOutputTokens = (int?)modelOptions?.MaxOutputTokens?.Eval(engine), - TopP = (float?)modelOptions?.TopP?.Eval(engine), - TopK = (int?)modelOptions?.TopK?.Eval(engine), - FrequencyPenalty = (float?)modelOptions?.FrequencyPenalty?.Eval(engine), - PresencePenalty = (float?)modelOptions?.PresencePenalty?.Eval(engine), - Seed = modelOptions?.Seed?.Eval(engine), + Temperature = modelOptions?.Temperature is { } temperature ? (float?)await temperature.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + MaxOutputTokens = modelOptions?.MaxOutputTokens is { } maxOutputTokens ? (int?)await maxOutputTokens.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + TopP = modelOptions?.TopP is { } topP ? (float?)await topP.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + TopK = modelOptions?.TopK is { } topK ? (int?)await topK.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + FrequencyPenalty = modelOptions?.FrequencyPenalty is { } frequencyPenalty ? (float?)await frequencyPenalty.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + PresencePenalty = modelOptions?.PresencePenalty is { } presencePenalty ? (float?)await presencePenalty.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, + Seed = modelOptions?.Seed is { } seed ? (int?)await seed.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, ResponseFormat = outputSchema?.AsChatResponseFormat(), ModelId = promptAgent.Model?.ModelNameHint, StopSequences = modelOptions?.StopSequences, - AllowMultipleToolCalls = modelOptions?.AllowMultipleToolCalls?.Eval(engine), + AllowMultipleToolCalls = modelOptions?.AllowMultipleToolCalls is { } allowMultipleToolCalls ? await allowMultipleToolCalls.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false) : default, ToolMode = modelOptions?.AsChatToolMode(), Tools = tools, AdditionalProperties = modelOptions?.GetAdditionalProperties(s_chatOptionProperties), diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs index 2a9b42e0873..fab9c4371de 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Threading; +using System.Threading.Tasks; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -15,8 +17,9 @@ public static class StringExpressionExtensions /// /// Expression to evaluate. /// Recalc engine to use for evaluation. + /// Cancellation token to use for the asynchronous operation. /// The evaluated string value, or null if the expression is null or cannot be evaluated. - public static string? Eval(this StringExpression? expression, RecalcEngine? engine) + public static async Task EvalAsync(this StringExpression? expression, RecalcEngine? engine, CancellationToken cancellationToken = default) { if (expression is null) { @@ -35,11 +38,11 @@ public static class StringExpressionExtensions if (expression.IsExpression) { - return engine.Eval(expression.ExpressionText!).ToString(); + return (await engine.EvalAsync(expression.ExpressionText!, cancellationToken: cancellationToken).ConfigureAwait(false)).ToString(); } else if (expression.IsVariableReference) { - var stringValue = engine.Eval(expression.VariableReference!.VariableName) as StringValue; + var stringValue = await engine.EvalAsync(expression.VariableReference!.VariableName, cancellationToken: cancellationToken).ConfigureAwait(false) as StringValue; return stringValue?.Value; } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs index 1cc24055d90..b7a0e2da26a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs @@ -24,7 +24,7 @@ public static Task CreateFromYamlAsync(this PromptAgentFactory agentFac Throw.IfNull(agentFactory); Throw.IfNullOrEmpty(agentYaml); - var agentDefinition = AgentBotElementYaml.FromYaml(agentYaml); + var agentDefinition = agentFactory.FromYaml(agentYaml); return agentFactory.CreateAsync( agentDefinition, diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs index 22d55178ba0..f9ea1f2deda 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.ObjectModel; @@ -15,30 +18,85 @@ namespace Microsoft.Agents.AI; /// public abstract class PromptAgentFactory { + private const int DefaultMaximumExpressionLength = 10000; + + private readonly IConfiguration? _configuration; + private readonly HashSet _allowedConfigurationVariables; + /// /// Initializes a new instance of the class. /// /// Optional , if none is provided a default instance will be created. - /// Optional configuration to be added as variables to the . - protected PromptAgentFactory(RecalcEngine? engine = null, IConfiguration? configuration = null) + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + protected PromptAgentFactory(RecalcEngine? engine = null, + IConfiguration? configuration = null) : this(engine, configuration, null, null, null) { - this.Engine = engine ?? new RecalcEngine(); + // BINARY COMPAT CONSTRUCTOR + } + + /// + /// Initializes a new instance of the class. + /// + /// Optional , if none is provided a default instance will be created. + /// Optional configuration used to resolve explicitly allowed environment variables referenced by the agent definition. + /// Configuration keys that may be exposed to Power Fx when the agent definition references them through Env. + /// Optional maximum length for Power Fx expressions evaluated by the factory-created engine. + /// Optional maximum nested call depth for Power Fx expressions evaluated by the factory-created engine. + protected PromptAgentFactory(RecalcEngine? engine, + IConfiguration? configuration, + IEnumerable? allowedConfigurationVariables, + int? maximumExpressionLength = null, + int? maximumCallDepth = null) + { + this.Engine = engine ?? new RecalcEngine(CreateConfig(maximumExpressionLength, maximumCallDepth)); + this._configuration = configuration; + this._allowedConfigurationVariables = new( + allowedConfigurationVariables ?? [], + StringComparer.OrdinalIgnoreCase); + } + + private static PowerFxConfig CreateConfig(int? maximumExpressionLength, int? maximumCallDepth) + { + PowerFxConfig config = new(Features.PowerFxV1) + { + MaximumExpressionLength = maximumExpressionLength ?? DefaultMaximumExpressionLength, + }; - if (configuration is not null) + if (maximumCallDepth is not null) { - foreach (var kvp in configuration.AsEnumerable()) - { - this.Engine.UpdateVariable(kvp.Key, kvp.Value ?? string.Empty); - } + config.MaxCallDepth = maximumCallDepth.Value; } + + return config; } /// /// Gets the Power Fx recalculation engine used to evaluate expressions in agent definitions. - /// This engine is configured with variables from the provided during construction. + /// This engine is configured with only explicitly allowed variables from the provided during construction. /// protected RecalcEngine Engine { get; } + /// + /// Adds allowed configuration values referenced through Env by the agent definition to the Power Fx engine. + /// + /// Definition of the agent to inspect. + protected void InitializeConfigurationVariables(GptComponentMetadata promptAgent) + { + if (this._configuration is null || this._allowedConfigurationVariables.Count == 0) + { + return; + } + + foreach (string variableName in AgentBotElementYaml.GetReferencedEnvironmentVariableNames(promptAgent).Where(this._allowedConfigurationVariables.Contains)) + { + this.Engine.UpdateVariable(variableName, this._configuration[variableName] ?? string.Empty); + } + } + + [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] + internal GptComponentMetadata FromYaml(string text) => + AgentBotElementYaml.FromYaml(text, this._configuration, this._allowedConfigurationVariables); + /// /// Create a from the specified . /// @@ -49,6 +107,7 @@ public async Task CreateAsync(GptComponentMetadata promptAgent, Cancell { Throw.IfNull(promptAgent); + this.InitializeConfigurationVariables(promptAgent); var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false) ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported."); Declarative.FeatureUsageMarker.MarkUsed(); return agent; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 054aa38b237..e12370a53ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -73,7 +73,11 @@ public static Workflow Build( string rootId = WorkflowActionVisitor.Steps.Root(workflowElement); WorkflowFormulaState state = new(options.CreateRecalcEngine()); - state.Initialize(workflowElement.WrapWithBot(), options.Configuration); + state.Initialize( + workflowElement.WrapWithBot(), + options.Configuration, + options.AllowedEnvironmentVariables, + options.AllowProcessEnvironmentVariableFallback); state.CaptureInitialState(); DeclarativeWorkflowExecutor rootExecutor = new(rootId, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 90439402dbd..b5b05b910d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics; using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Extensions.Configuration; @@ -37,6 +38,16 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public IConfiguration? Configuration { get; init; } + /// + /// Gets the configuration or process environment variable names that may be exposed through the workflow Env scope. + /// + public IEnumerable? AllowedEnvironmentVariables { get; init; } + + /// + /// Gets a value indicating whether the workflow may fall back to process environment variables for allowed Env names missing from . + /// + public bool AllowProcessEnvironmentVariableFallback { get; init; } + /// /// Optionally identifies a continued workflow conversation. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs index 1b92235eee3..f7b2bfce695 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -38,17 +38,38 @@ public static ValueTask QueueStateResetAsync(this IWorkflowContext context, Prop public static ValueTask QueueStateUpdateAsync(this IWorkflowContext context, PropertyPath variablePath, TValue? value, CancellationToken cancellationToken = default) => context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), value, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken); + public static ValueTask QueueStateUpdateAsync( + this IWorkflowContext context, + PropertyPath variablePath, + TValue? value, + SensitivityLevel sensitivity, + CancellationToken cancellationToken = default) + { + string variableName = Throw.IfNull(variablePath.VariableName); + string namespaceAlias = Throw.IfNull(variablePath.NamespaceAlias); + + return context is DeclarativeWorkflowContext declarativeContext + ? declarativeContext.QueueStateUpdateAsync(variableName, value, namespaceAlias, sensitivity, cancellationToken) + : context.QueueStateUpdateAsync(variableName, value, namespaceAlias, cancellationToken); + } + public static async ValueTask QueueEnvironmentUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) { DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context); - await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.Environment, allowSystem: true, cancellationToken).ConfigureAwait(false); + await declarativeContext.UpdateStateAsync( + key, + value, + VariableScopeNames.Environment, + allowSystem: true, + sensitivity: SensitivityLevel.Sensitive, + cancellationToken: cancellationToken).ConfigureAwait(false); declarativeContext.State.Bind(); } public static async ValueTask QueueSystemUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) { DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context); - await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true, cancellationToken).ConfigureAwait(false); + await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true, cancellationToken: cancellationToken).ConfigureAwait(false); declarativeContext.State.Bind(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 5d052c64d3d..597ea831296 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -24,8 +24,6 @@ internal abstract class DeclarativeActionExecutor(TAction model, Workfl internal abstract class DeclarativeActionExecutor : Executor, IResettableExecutor, IModeledAction { - private readonly WorkflowFormulaState _state; - protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState state) : base(model.Id.Value) { @@ -34,7 +32,7 @@ protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState sta throw new DeclarativeModelException($"Missing required properties for element: {model.GetId()} ({model.GetType().Name})."); } - this._state = state; + this.State = state; this.Model = model; } @@ -51,9 +49,11 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui public string ParentId { get => field ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); } - public RecalcEngine Engine => this._state.Engine; + public RecalcEngine Engine => this.State.Engine; + + public WorkflowExpressionEngine Evaluator => this.State.Evaluator; - public WorkflowExpressionEngine Evaluator => this._state.Evaluator; + protected WorkflowFormulaState State { get; } internal ILogger Logger { get; set; } = NullLogger.Instance; @@ -64,7 +64,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui /// public virtual ValueTask ResetAsync() { - this._state.Reset(); + this.State.Reset(); return default; } @@ -89,7 +89,7 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf try { - object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._state), cancellationToken).ConfigureAwait(false); + object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this.State), cancellationToken).ConfigureAwait(false); Debug.WriteLine($"RESULT #{this.Id} - {result ?? "(null)"}"); if (this.EmitResultEvent) @@ -123,19 +123,21 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf /// This must be overridden to restore any state that was saved during checkpointing. /// protected override ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - this._state.RestoreAsync(context, cancellationToken); + this.State.RestoreAsync(context, cancellationToken); - protected async ValueTask AssignAsync(PropertyPath? targetPath, FormulaValue result, IWorkflowContext context) + protected async ValueTask AssignAsync(PropertyPath? targetPath, FormulaValue result, IWorkflowContext context, SensitivityLevel sensitivity = SensitivityLevel.None) { if (targetPath is null) { return; } - await context.QueueStateUpdateAsync(targetPath, result).ConfigureAwait(false); + await context.QueueStateUpdateAsync(targetPath, result, sensitivity).ConfigureAwait(false); + string variableName = targetPath.VariableName ?? throw new DeclarativeActionException($"Invalid variable reference: '{targetPath}'."); + this.State.SetSensitivity(variableName, targetPath.NamespaceAlias, sensitivity); #if DEBUG - string? resultValue = result.Format(); + string? resultValue = sensitivity == SensitivityLevel.Sensitive ? "" : result.Format(); string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; Debug.WriteLine( $""" diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs index 6616aa5d00c..183b2e785e7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs @@ -58,7 +58,7 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null, Cancellati // Copy keys to array to avoid modifying collection during enumeration. foreach (string key in this.State.Keys(scopeName).ToArray()) { - await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false); + await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName, allowSystem: false, cancellationToken: cancellationToken).ConfigureAwait(false); } } else @@ -73,7 +73,18 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null, Cancellati /// public async ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) { - await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false); + await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, cancellationToken: cancellationToken).ConfigureAwait(false); + this.State.Bind(); + } + + internal async ValueTask QueueStateUpdateAsync( + string key, + T? value, + string? scopeName, + SensitivityLevel sensitivity, + CancellationToken cancellationToken = default) + { + await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, sensitivity: sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); this.State.Bind(); } @@ -137,7 +148,13 @@ public ValueTask> ReadStateKeysAsync(string? scopeName = null, C public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) => this.Source.SendMessageAsync(message, targetId, cancellationToken); - public ValueTask UpdateStateAsync(string key, T? value, string? scopeName, bool allowSystem, CancellationToken cancellationToken = default) + public ValueTask UpdateStateAsync( + string key, + T? value, + string? scopeName, + bool allowSystem, + SensitivityLevel sensitivity = SensitivityLevel.None, + CancellationToken cancellationToken = default) { bool isManagedScope = scopeName is not null && // null scope cannot be managed @@ -165,47 +182,61 @@ scopeName is not null && // null scope cannot be managed _ => QueueNativeStateAsync(value), }; - ValueTask QueueEmptyStateAsync() + async ValueTask QueueEmptyStateAsync() { if (isManagedScope) { - this.State.Set(key, FormulaValue.NewBlank(), scopeName); + this.State.Set(key, FormulaValue.NewBlank(), scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); } - ValueTask QueueFormulaStateAsync(FormulaValue formulaValue) + async ValueTask QueueFormulaStateAsync(FormulaValue formulaValue) { if (isManagedScope) { - this.State.Set(key, formulaValue, scopeName); + this.State.Set(key, formulaValue, scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); } - ValueTask QueueDataValueStateAsync(DataValue dataValue) + async ValueTask QueueDataValueStateAsync(DataValue dataValue) { FormulaValue formulaValue = dataValue.ToFormula(); if (isManagedScope) { - this.State.Set(key, formulaValue, scopeName); + this.State.Set(key, formulaValue, scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); } - ValueTask QueueNativeStateAsync(object rawValue) + async ValueTask QueueNativeStateAsync(object rawValue) { FormulaValue formulaValue = rawValue.ToFormula(); if (isManagedScope) { - this.State.Set(key, formulaValue, scopeName); + this.State.Set(key, formulaValue, scopeName, sensitivity); } - return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); + await this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken).ConfigureAwait(false); + await this.QueueSensitivityUpdateAsync(key, scopeName, sensitivity, cancellationToken).ConfigureAwait(false); + } + } + + private ValueTask QueueSensitivityUpdateAsync(string key, string? scopeName, SensitivityLevel sensitivity, CancellationToken cancellationToken) + { + if (scopeName is null || (!ManagedScopes.Contains(scopeName) && scopeName != VariableScopeNames.Environment)) + { + return default; } + + return this.Source.QueueStateUpdateAsync(key, sensitivity, WorkflowFormulaState.GetSensitivityScopeName(scopeName), cancellationToken); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs index 69cca12fafd..3217ea774af 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs @@ -36,6 +36,27 @@ public static class IWorkflowContextExtensions public static ValueTask FormatTemplateAsync(this IWorkflowContext context, string line, CancellationToken cancellationToken = default) => context.FormatTemplateAsync([line], cancellationToken); + /// + /// Formats a template line using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within the line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template line to format. + /// The formatted line and its sensitivity metadata. + public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, string line) => + context.FormatTemplateWithSensitivityAsync(line, default); + + /// + /// Formats a template line using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within the line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template line to format. + /// A token that propagates notification when operation should be canceled. + /// The formatted line and its sensitivity metadata. + public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, string line, CancellationToken cancellationToken) => + context.FormatTemplateWithSensitivityAsync([line], cancellationToken); + /// /// Formats a template lines using the workflow's declarative state /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line. @@ -52,16 +73,44 @@ public static ValueTask FormatTemplateAsync(this IWorkflowContext contex /// var text = await context.FormatAsync("Hello @{User.Name}", "Count: @{Metrics.Count}"); /// public static async ValueTask FormatTemplateAsync(this IWorkflowContext context, IEnumerable lines, CancellationToken cancellationToken = default) + { + EvaluationResult result = await context.FormatTemplateWithSensitivityAsync(lines, cancellationToken).ConfigureAwait(false); + ThrowIfSensitive(result.Sensitivity); + return result.Value; + } + + /// + /// Formats a template lines using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template lines to format. + /// The formatted lines and their sensitivity metadata. + public static ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, IEnumerable lines) => + context.FormatTemplateWithSensitivityAsync(lines, default); + + /// + /// Formats a template lines using the workflow's declarative state + /// and evaluating any embedded expressions (e.g., Power Fx) contained within each line. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// The template lines to format. + /// A token that propagates notification when operation should be canceled. + /// The formatted lines and their sensitivity metadata. + public static async ValueTask> FormatTemplateWithSensitivityAsync(this IWorkflowContext context, IEnumerable lines, CancellationToken cancellationToken) { WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false); StringBuilder builder = new(); + SensitivityLevel sensitivity = SensitivityLevel.None; foreach (string line in lines) { - builder.AppendLine(state.Engine.Format(TemplateLine.Parse(line))); + EvaluationResult result = state.Evaluator.Format(TemplateLine.Parse(line)); + sensitivity = MaxSensitivity(sensitivity, result.Sensitivity); + builder.AppendLine(result.Value); } - return builder.ToString(); + return new(builder.ToString(), sensitivity); } /// @@ -82,12 +131,27 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext /// A token that propagates notification when operation should be canceled. /// The evaluated expression value public static async ValueTask EvaluateValueAsync(this IWorkflowContext context, string expression, CancellationToken cancellationToken = default) + { + EvaluationResult result = await context.EvaluateValueWithSensitivityAsync(expression, cancellationToken).ConfigureAwait(false); + ThrowIfSensitive(result.Sensitivity); + return result.Value; + } + + /// + /// Evaluate an expression using the workflow's declarative state. + /// + /// The type of the evaluated value. + /// The workflow execution context used to restore persisted state prior to formatting. + /// The expression to evaluate. + /// A token that propagates notification when operation should be canceled. + /// The evaluated expression value and its sensitivity metadata. + public static async ValueTask> EvaluateValueWithSensitivityAsync(this IWorkflowContext context, string expression, CancellationToken cancellationToken = default) { WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false); EvaluationResult result = state.Evaluator.GetValue(ValueExpression.Expression(expression)); - return (TValue?)result.Value.ToObject(); + return new((TValue?)result.Value.ToObject(), result.Sensitivity); } /// @@ -103,10 +167,74 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext WorkflowFormulaState state = await context.GetStateAsync(cancellationToken).ConfigureAwait(false); EvaluationResult result = state.Evaluator.GetValue(ValueExpression.Expression(expression)); + ThrowIfSensitive(result.Sensitivity); return result.Value.AsList(); } + /// + /// Reads a state value together with its sensitivity metadata. + /// + /// The type of the state value. + /// The workflow execution context used to read state. + /// The key of the state value. + /// An optional name that specifies the scope to read. If null, the default scope is used. + /// A token that propagates notification when operation should be canceled. + /// The state value and its sensitivity metadata. + public static async ValueTask> ReadStateWithSensitivityAsync( + this IWorkflowContext context, + string key, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + if (context is DeclarativeWorkflowContext declarativeContext) + { + string effectiveScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + TValue? declarativeValue = await context.ReadStateAsync(key, effectiveScopeName, cancellationToken).ConfigureAwait(false); + SensitivityLevel declarativeSensitivity = declarativeContext.State.GetSensitivity(key, effectiveScopeName); + return new(declarativeValue, declarativeSensitivity); + } + + string plainScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + TValue? value = await context.ReadStateAsync(key, plainScopeName, cancellationToken).ConfigureAwait(false); + SensitivityLevel sensitivity = ShouldPersistSensitivity(plainScopeName) + ? await context.ReadStateAsync(key, WorkflowFormulaState.GetSensitivityScopeName(plainScopeName), cancellationToken).ConfigureAwait(false) + : SensitivityLevel.None; + return new(value, sensitivity); + } + + /// + /// Queues a state update using sensitivity metadata carried with the value. + /// + /// The type of the state value. + /// The workflow execution context used to queue state updates. + /// The key of the state value. + /// The value and sensitivity metadata to store. + /// An optional name that specifies the scope to update. If null, the default scope is used. + /// A token that propagates notification when operation should be canceled. + /// A task representing the queued state update. + public static async ValueTask QueueStateUpdateWithSensitivityAsync( + this IWorkflowContext context, + string key, + EvaluationResult value, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + if (context is DeclarativeWorkflowContext declarativeContext) + { + string effectiveScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + await declarativeContext.QueueStateUpdateAsync(key, value.Value, effectiveScopeName, value.Sensitivity, cancellationToken).ConfigureAwait(false); + return; + } + + string plainScopeName = scopeName ?? WorkflowFormulaState.DefaultScopeName; + await context.QueueStateUpdateAsync(key, value.Value, plainScopeName, cancellationToken).ConfigureAwait(false); + if (ShouldPersistSensitivity(plainScopeName)) + { + await context.QueueStateUpdateAsync(key, value.Sensitivity, WorkflowFormulaState.GetSensitivityScopeName(plainScopeName), cancellationToken).ConfigureAwait(false); + } + } + /// /// Convert the result of an expression to the specified target type. /// @@ -127,7 +255,7 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext /// The workflow execution context used to restore persisted state prior to formatting. /// Describes the target type for the value conversion. /// The key of the state value. - /// An optional name that specifies the scope to read.If null, the default scope is used. + /// An optional name that specifies the scope to read. If null, the default scope is used. /// A token that propagates notification when operation should be canceled. /// The converted value public static async ValueTask ConvertValueAsync(this IWorkflowContext context, VariableType targetType, string key, string? scopeName = null, CancellationToken cancellationToken = default) @@ -136,13 +264,34 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext return sourceValue.ConvertType(targetType); } + /// + /// Convert the variable value to the specified target type while preserving sensitivity metadata. + /// + /// The workflow execution context used to restore persisted state prior to formatting. + /// Describes the target type for the value conversion. + /// The key of the state value. + /// An optional name that specifies the scope to read. If null, the default scope is used. + /// A token that propagates notification when operation should be canceled. + /// The converted value and its sensitivity metadata. + public static async ValueTask> ConvertValueWithSensitivityAsync( + this IWorkflowContext context, + VariableType targetType, + string key, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + EvaluationResult sourceValue = await context.ReadStateWithSensitivityAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + object? convertedValue = sourceValue.Value is null ? null : sourceValue.Value.ToFormula().ToObject().ConvertType(targetType); + return new(convertedValue, sourceValue.Sensitivity); + } + /// /// Evaluate an expression using the workflow's declarative state. /// /// The type of the list element. /// The workflow execution context used to restore persisted state prior to formatting. /// The key of the state value. - /// An optional name that specifies the scope to read.If null, the default scope is used. + /// An optional name that specifies the scope to read. If null, the default scope is used. /// A token that propagates notification when operation should be canceled. /// The evaluated list expression public static async ValueTask?> ReadListAsync(this IWorkflowContext context, string key, string? scopeName = null, CancellationToken cancellationToken = default) @@ -164,4 +313,20 @@ private static async Task GetStateAsync(this IWorkflowCont return state; } + + private static void ThrowIfSensitive(SensitivityLevel sensitivity) + { + if (sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException("Cannot return sensitive workflow expression value."); + } + } + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; + + private static bool ShouldPersistSensitivity(string scopeName) => + DeclarativeWorkflowContext.ManagedScopes.Contains(scopeName) || + scopeName == VariableScopeNames.Environment || + scopeName == VariableScopeNames.System; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs index 80f6e69b60d..3a18a06c948 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Frozen; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; @@ -21,6 +23,8 @@ public abstract class RootExecutor : Executor, IResettableExecut private readonly ResponseAgentProvider _agentProvider; private readonly WorkflowFormulaState _state; private readonly Func? _inputTransform; + private readonly bool _allowProcessEnvironmentVariableFallback; + private readonly FrozenSet _allowedEnvironmentVariables; private string? _conversationId; @@ -42,6 +46,8 @@ protected RootExecutor(string id, DeclarativeWorkflowOptions options, Func - /// Initializes the specified variables from if available; - /// otherwise falls back to the process environment variables. + /// Initializes the specified variables from if available. + /// Only names included in are initialized. + /// Process environment variables are used only when enabled by . /// /// The workflow execution context providing messaging and state services. /// The set of variable names to initialize. /// A representing the asynchronous execution operation. protected async ValueTask InitializeEnvironmentAsync(IWorkflowContext context, params string[] variableNames) { - foreach (string variableName in variableNames) + foreach (string variableName in variableNames.Where(this._allowedEnvironmentVariables.Contains)) { await context.QueueEnvironmentUpdateAsync(variableName, GetEnvironmentVariable(variableName)).ConfigureAwait(false); } string GetEnvironmentVariable(string name) { - if (this._configuration is not null) - { - return this._configuration[name] ?? string.Empty; - } - - return Environment.GetEnvironmentVariable(name) ?? string.Empty; + string? configurationValue = this._configuration?[name]; + return configurationValue ?? (this._allowProcessEnvironmentVariableFallback ? Environment.GetEnvironmentVariable(name) ?? string.Empty : string.Empty); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs index 21c14de546f..16740053de6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs @@ -7,6 +7,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -42,7 +43,13 @@ private IEnumerable GetContent() { foreach (AddConversationMessageContent content in this.Model.Content) { - AIContent? messageContent = content.Type.Value.ToContent(this.Engine.Format(content.Value), content.MediaType); + EvaluationResult contentResult = this.Evaluator.Format(content.Value); + if (contentResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive conversation message content: {this.Id}."); + } + + AIContent? messageContent = content.Type.Value.ToContent(contentResult.Value, content.MediaType); if (messageContent is not null) { yield return messageContent; @@ -57,8 +64,12 @@ private IEnumerable GetContent() return null; } - RecordDataValue? metadataValue = this.Evaluator.GetValue(this.Model.Metadata).Value; + EvaluationResult metadataResult = this.Evaluator.GetValue(this.Model.Metadata); + if (metadataResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive conversation message metadata: {this.Id}."); + } - return metadataValue.ToMetadata(); + return metadataResult.Value.ToMetadata(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs index 381abcb84dc..3b63ffeb26a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs @@ -45,6 +45,11 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages Throw.IfNull(this.Model.Messages, $"{nameof(this.Model)}.{nameof(this.Model.Messages)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Messages); + if (expressionResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive conversation messages: {this.Id}."); + } + DataValue messages = expressionResult.Value; return messages.ToChatMessages(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs index 41fd8468e0e..e110ee4272a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -25,6 +25,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st { throw this.Exception($"Require '{variablePath}' to be a table, not: '{table.GetType().Name}'."); } + SensitivityLevel tableSensitivity = this.GetSensitivity(variablePath); TableChangeType changeType = this.Model.ChangeType.Value; switch (this.Model.ChangeType.Value) @@ -33,6 +34,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st ValueExpression addItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); EvaluationResult addResult = this.Evaluator.GetValue(addItemValue); FormulaValue addValue = addResult.Value.ToFormula(); + SensitivityLevel addSensitivity = MaxSensitivity(tableSensitivity, addResult.Sensitivity); RecordType recordType = tableValue.Type.ToRecord(); RecordValue newRecord; TableValue resultTable; @@ -47,35 +49,36 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); resultTable = tableValue; } - await this.AssignAsync(variablePath, resultTable, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, resultTable, context, addSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context, addSensitivity).ConfigureAwait(false); break; case TableChangeType.Remove: ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); EvaluationResult removeResult = this.Evaluator.GetValue(removeItemValue); + SensitivityLevel removeSensitivity = MaxSensitivity(tableSensitivity, removeResult.Sensitivity); if (removeResult.Value is TableDataValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, removeSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context, removeSensitivity).ConfigureAwait(false); } break; case TableChangeType.Clear: await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); break; case TableChangeType.TakeFirst: RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } break; case TableChangeType.TakeLast: @@ -83,12 +86,12 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } break; } @@ -120,4 +123,10 @@ IEnumerable GetValues() } } } + + private SensitivityLevel GetSensitivity(PropertyPath? path) => + path?.VariableName is string variableName ? this.State.GetSensitivity(variableName, path.NamespaceAlias) : SensitivityLevel.None; + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 79b32428a87..7475916bf11 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -25,6 +25,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat { throw this.Exception($"Require '{this.Model.ItemsVariable.Path}' to be a table, not: '{table.GetType().Name}'."); } + SensitivityLevel tableSensitivity = this.GetSensitivity(this.Model.ItemsVariable); EditTableOperation? changeType = this.Model.ChangeType; if (changeType is AddItemOperation addItemOperation) @@ -32,6 +33,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(addItemValue); FormulaValue addValue = expressionResult.Value.ToFormula(); + SensitivityLevel mutationSensitivity = MaxSensitivity(tableSensitivity, expressionResult.Sensitivity); RecordType recordType = tableValue.Type.ToRecord(); TableValue resultTable; if (!recordType.FieldNames.Any() && !tableValue.Rows.Any()) @@ -45,21 +47,22 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); resultTable = tableValue; } - await this.AssignAsync(this.Model.ItemsVariable, resultTable, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, resultTable, context, mutationSensitivity).ConfigureAwait(false); } else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false); } else if (changeType is RemoveItemOperation removeItemOperation) { ValueExpression removeItemValue = Throw.IfNull(removeItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(removeItemValue); + SensitivityLevel mutationSensitivity = MaxSensitivity(tableSensitivity, expressionResult.Sensitivity); if (expressionResult.Value.ToFormula() is TableValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, mutationSensitivity).ConfigureAwait(false); } } else if (changeType is TakeLastItemOperation takeLastOperation) @@ -68,12 +71,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } } else if (changeType is TakeFirstItemOperation takeFirstOperation) @@ -82,12 +85,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); - await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context, tableSensitivity).ConfigureAwait(false); + await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context, tableSensitivity).ConfigureAwait(false); } else { - await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context, tableSensitivity).ConfigureAwait(false); } } @@ -118,4 +121,10 @@ IEnumerable GetValues() } } } + + private SensitivityLevel GetSensitivity(PropertyPath? path) => + path?.VariableName is string variableName ? this.State.GetSensitivity(variableName, path.NamespaceAlias) : SensitivityLevel.None; + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index f154ad7f97b..d829f0ba8ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -26,9 +26,11 @@ public static class Steps private const string IndexStateKey = nameof(_index); private const string ValuesStateKey = nameof(_values); private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); private int _index; private FormulaValue[] _values; + private SensitivityLevel _sensitivity; public ForeachExecutor(Foreach model, WorkflowFormulaState state) : base(model, state) @@ -55,6 +57,7 @@ public ForeachExecutor(Foreach model, WorkflowFormulaState state) { this._values = [expressionResult.Value.ToFormula()]; } + this._sensitivity = expressionResult.Sensitivity; await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); @@ -67,7 +70,15 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { FormulaValue value = this._values[this._index]; - await context.QueueStateUpdateAsync(Throw.IfNull(this.Model.Value), value, cancellationToken).ConfigureAwait(false); + PropertyPath valuePath = Throw.IfNull(this.Model.Value); + if (context is DeclarativeWorkflowContext) + { + await this.AssignAsync(valuePath, value, context, this._sensitivity).ConfigureAwait(false); + } + else + { + await context.QueueStateUpdateAsync(valuePath, value, cancellationToken).ConfigureAwait(false); + } if (this.Model.Index is not null) { @@ -122,6 +133,7 @@ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); await context.QueueStateUpdateAsync(ValuesStateKey, portableValues, cancellationToken: cancellationToken).ConfigureAwait(false); await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); } @@ -147,5 +159,6 @@ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext co this._values = [.. savedValues.Select(value => value.ToFormula())]; this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 6d152584530..4b33bdc8fe7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -156,6 +156,11 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable expressionResult = this.Evaluator.GetValue(this.AgentInput.Messages); + if (expressionResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive agent input messages: {this.Id}."); + } + userInput = expressionResult.Value; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 57fe319aaff..16f061f37a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -30,7 +30,7 @@ internal sealed class ParseValueExecutor(ParseValue model, WorkflowFormulaState object? parsedResult = expressionResult.Value.ToObject().ConvertType(targetType); parsedValue = parsedResult.ToFormula(); - await this.AssignAsync(this.Model.Variable.Path, parsedValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable.Path, parsedValue, context, expressionResult.Sensitivity).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs index 4ad88dd40cb..dacdb9151f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; @@ -161,13 +162,13 @@ private async ValueTask PromptAsync(IWorkflowContext context, int actualCount, C long repeatCount = this.Evaluator.GetValue(this.Model.RepeatCount).Value; if (actualCount >= repeatCount) { - DataValue defaultValue = DataValue.Blank(); + EvaluationResult defaultValue = new(DataValue.Blank(), SensitivityLevel.None); if (this.Model.DefaultValue is not null) { ValueExpression defaultValueExpression = Throw.IfNull(this.Model.DefaultValue); - defaultValue = this.Evaluator.GetValue(defaultValueExpression).Value; + defaultValue = this.Evaluator.GetValue(defaultValueExpression); } - await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.Value.ToFormula(), context, defaultValue.Sensitivity).ConfigureAwait(false); string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse); await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false); // Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh. @@ -187,6 +188,12 @@ private string FormatPrompt(ActivityTemplateBase? promptTemplate) return string.Empty; } - return this.Engine.Format(messageActivity.Text).Trim(); + EvaluationResult promptResult = this.Evaluator.Format(messageActivity.Text); + if (promptResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive question prompt: {this.Id}."); + } + + return promptResult.Value.Trim(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 3b9794b197f..4763422b105 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -3,10 +3,10 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -18,7 +18,13 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt { if (this.Model.Activity is MessageActivityTemplate messageActivity) { - string activityText = this.Engine.Format(messageActivity.Text).Trim(); + EvaluationResult activityResult = this.Evaluator.Format(messageActivity.Text); + if (activityResult.Sensitivity == SensitivityLevel.Sensitive) + { + throw new DeclarativeActionException($"Cannot send sensitive activity text: {this.Id}."); + } + + string activityText = activityResult.Value.Trim(); await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs index e81126e9a5e..1015c0a0dd1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetMultipleVariablesExecutor.cs @@ -31,7 +31,7 @@ internal sealed class SetMultipleVariablesExecutor(SetMultipleVariables model, W { EvaluationResult expressionResult = this.Evaluator.GetValue(assignment.Value); - await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync(assignment.Variable, expressionResult.Value.ToFormula(), context, expressionResult.Sensitivity).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs index 37b8d43e8a5..8d7f2924c4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs @@ -2,10 +2,10 @@ using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; @@ -19,9 +19,9 @@ internal sealed class SetTextVariableExecutor(SetTextVariable model, WorkflowFor Throw.IfNull(this.Model.Variable); Throw.IfNull(this.Model.Value); - FormulaValue expressionResult = FormulaValue.New(this.Engine.Format(this.Model.Value)); + EvaluationResult expressionResult = this.Evaluator.Format(this.Model.Value); - await this.AssignAsync(this.Model.Variable.Path, expressionResult, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable.Path, FormulaValue.New(expressionResult.Value), context, expressionResult.Sensitivity).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index 6fd4002df5c..d75a47ca00a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -21,7 +21,7 @@ internal sealed class SetVariableExecutor(SetVariable model, WorkflowFormulaStat EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Value); - await this.AssignAsync(this.Model.Variable.Path, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable.Path, expressionResult.Value.ToFormula(), context, expressionResult.Sensitivity).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index 6c6fe5649fe..c6cc9cbea65 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -37,7 +37,6 @@ PowerFxConfig CreateConfig() config.MaxCallDepth = maximumCallDepth.Value; } - config.EnableSetFunction(); config.AddFunction(new AgentMessage()); config.AddFunction(new UserMessage()); config.AddFunction(new MessageText.StringInput()); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 95b8f9ab93d..c46016317f3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -37,22 +37,43 @@ public static WorkflowTypeInfo Describe(this TElement workflowElement) [.. semanticModel.GetVariables(workflowElement.SchemaName.Value).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())]); } - public static void Initialize(this WorkflowFormulaState scopes, TElement workflowElement, IConfiguration? configuration) where TElement : BotElement, IDialogBase + public static void Initialize( + this WorkflowFormulaState scopes, + TElement workflowElement, + IConfiguration? configuration, + IEnumerable? allowedEnvironmentVariables, + bool allowProcessEnvironmentVariableFallback) where TElement : BotElement, IDialogBase { scopes.InitializeSystem(); SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); - scopes.InitializeEnvironment(semanticModel, configuration); + scopes.InitializeEnvironment(semanticModel, configuration, allowedEnvironmentVariables, allowProcessEnvironmentVariableFallback); scopes.InitializeDefaults(semanticModel, workflowElement.SchemaName.Value); } - private static void InitializeEnvironment(this WorkflowFormulaState scopes, SemanticModel semanticModel, IConfiguration? configuration) + private static void InitializeEnvironment( + this WorkflowFormulaState scopes, + SemanticModel semanticModel, + IConfiguration? configuration, + IEnumerable? allowedEnvironmentVariables, + bool allowProcessEnvironmentVariableFallback) { + HashSet allowedVariables = new(allowedEnvironmentVariables ?? [], StringComparer.Ordinal); foreach (string variableName in semanticModel.GetAllEnvironmentVariablesReferencedInTheBot()) { - string? environmentValue = configuration is not null ? configuration[variableName] : Environment.GetEnvironmentVariable(variableName); + if (!allowedVariables.Contains(variableName)) + { + continue; + } + + string? environmentValue = configuration?[variableName]; + if (environmentValue is null && allowProcessEnvironmentVariableFallback) + { + environmentValue = Environment.GetEnvironmentVariable(variableName); + } + FormulaValue variableValue = string.IsNullOrEmpty(environmentValue) ? FormulaType.String.NewBlank() : FormulaValue.New(environmentValue); - scopes.Set(variableName, variableValue, VariableScopeNames.Environment); + scopes.Set(variableName, variableValue, VariableScopeNames.Environment, SensitivityLevel.Sensitive); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index a22a857635a..eb0232d1b0f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -3,11 +3,13 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.ObjectModel; using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Agents.ObjectModel.Exceptions; using Microsoft.PowerFx; +using Microsoft.PowerFx.Syntax; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; @@ -15,11 +17,13 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx; internal sealed class WorkflowExpressionEngine { - private readonly RecalcEngine _engine; + private readonly WorkflowFormulaState _state; + private readonly ParserOptions? _parserOptions; - public WorkflowExpressionEngine(RecalcEngine engine) + public WorkflowExpressionEngine(WorkflowFormulaState state) { - this._engine = engine; + this._state = state; + this._parserOptions = state.AllowsSideEffects ? new ParserOptions { AllowsSideEffects = true } : null; } public EvaluationResult GetValue(BoolExpression boolean) => this.Evaluate(boolean); @@ -41,6 +45,55 @@ public WorkflowExpressionEngine(RecalcEngine engine) public EvaluationResult GetValue(EnumExpression expression) where TValue : EnumWrapper => this.Evaluate(expression); + public EvaluationResult Format(IEnumerable template) + { + Throw.IfNull(template); + + SensitivityLevel sensitivity = SensitivityLevel.None; + List segments = []; + foreach (EvaluationResult result in template.Select(this.Format)) + { + sensitivity = MaxSensitivity(sensitivity, result.Sensitivity); + segments.Add(result.Value); + } + + return new(string.Concat(segments), sensitivity); + } + + public EvaluationResult Format(TemplateLine? line) + { + if (line is null) + { + return new(string.Empty, SensitivityLevel.None); + } + + SensitivityLevel sensitivity = SensitivityLevel.None; + List segments = []; + foreach (EvaluationResult result in line.Segments.Select(this.Format)) + { + sensitivity = MaxSensitivity(sensitivity, result.Sensitivity); + segments.Add(result.Value); + } + + return new(string.Concat(segments), sensitivity); + } + + private EvaluationResult Format(TemplateSegment segment) + { + if (segment is TextSegment textSegment) + { + return new(textSegment.Value ?? string.Empty, SensitivityLevel.None); + } + + if (segment is ExpressionSegment { Expression: not null } expressionSegment) + { + EvaluationResult result = this.EvaluateScope(expressionSegment.Expression); + return new(result.Value.Format(), result.Sensitivity); + } + + throw new DeclarativeModelException($"Unsupported segment type: {segment.GetType().Name}"); + } + private EvaluationResult Evaluate(BoolExpression expression) { Throw.IfNull(expression); @@ -274,13 +327,136 @@ private EvaluationResult EvaluateScope(ExpressionBase expression) expression.VariableReference?.ToString() : expression.ExpressionText; - FormulaValue result = this._engine.Eval(expressionText); + FormulaValue result = this._state.Engine.Eval(expressionText, options: this._parserOptions); if (result is ErrorValue errorValue) { throw new DeclarativeActionException(errorValue.Format()); } - return new(result, SensitivityLevel.None); + return new(result, this.GetSensitivity(expression)); + } + + private SensitivityLevel GetSensitivity(ExpressionBase expression) + { + if (expression.VariableReference is { VariableName: string variableName }) + { + return GetReferenceSensitivity(expression.VariableReference.NamespaceAlias, variableName); + } + + string? expressionText = expression.ExpressionText; + if (string.IsNullOrWhiteSpace(expressionText)) + { + return SensitivityLevel.None; + } + + CheckResult checkResult = this._state.Engine.Check(expressionText, options: this._parserOptions); + checkResult.ThrowOnErrors(); + + SensitivityLevel sensitivity = SensitivityLevel.None; + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(checkResult.Parse.Root)) + { + sensitivity = MaxSensitivity(sensitivity, GetReferenceSensitivity(reference.ScopeName, reference.VariableName)); + } + + return sensitivity; + + SensitivityLevel GetReferenceSensitivity(string? scopeName, string variableName) => + scopeName is null && VariableScopeNames.IsValidName(variableName) + ? this._state.GetScopeSensitivity(variableName) + : this._state.GetSensitivity(variableName, scopeName); + } + + private static IEnumerable<(string? ScopeName, string VariableName)> GetVariableReferences(TexlNode node) + { + switch (node) + { + case DottedNameNode dottedNameNode: + if (TryGetDottedReference(dottedNameNode, out (string? ScopeName, string VariableName) dottedReference)) + { + yield return dottedReference; + } + else + { + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(dottedNameNode.Left)) + { + yield return reference; + } + } + yield break; + + case FirstNameNode firstNameNode: + yield return (null, firstNameNode.Ident.Name.Value); + yield break; + + case AsNode asNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(asNode.Left)) + { + yield return reference; + } + yield break; + + case BinaryOpNode binaryOpNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(binaryOpNode.Left)) + { + yield return reference; + } + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(binaryOpNode.Right)) + { + yield return reference; + } + yield break; + + case UnaryOpNode unaryOpNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(unaryOpNode.Child)) + { + yield return reference; + } + yield break; + + case CallNode callNode: + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(callNode.Args)) + { + yield return reference; + } + yield break; + + case VariadicBase variadicBase: + foreach (TexlNode childNode in variadicBase.ChildNodes) + { + foreach ((string? ScopeName, string VariableName) reference in GetVariableReferences(childNode)) + { + yield return reference; + } + } + yield break; + } } + + private static bool TryGetDottedReference(DottedNameNode dottedNameNode, out (string? ScopeName, string VariableName) reference) + { + List names = []; + TexlNode node = dottedNameNode; + while (node is DottedNameNode current) + { + names.Add(current.Right.Name.Value); + node = current.Left; + } + + if (node is not FirstNameNode firstNameNode) + { + reference = default; + return false; + } + + names.Add(firstNameNode.Ident.Name.Value); + names.Reverse(); + reference = names.Count > 1 && VariableScopeNames.IsValidName(names[0]) + ? (names[0], names[1]) + : (null, names[0]); + return true; + } + + private static SensitivityLevel MaxSensitivity(SensitivityLevel left, SensitivityLevel right) => + left == SensitivityLevel.Sensitive || right == SensitivityLevel.Sensitive ? SensitivityLevel.Sensitive : SensitivityLevel.None; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs index aaff60b08b4..87f726a77e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics; @@ -27,6 +28,8 @@ internal sealed class WorkflowFormulaState VariableScopeNames.System, ]; + private const string SensitivityScopePrefix = "__Microsoft_Agents_AI_Workflows_Declarative_Sensitivity:"; + private readonly Dictionary _scopes; private Dictionary _initialScopes; @@ -37,13 +40,16 @@ internal sealed class WorkflowFormulaState public WorkflowExpressionEngine Evaluator { get; } - public WorkflowFormulaState(RecalcEngine engine) + public bool AllowsSideEffects { get; } + + public WorkflowFormulaState(RecalcEngine engine, bool allowsSideEffects = false) { this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => GetScopeName(scopeName), _ => new WorkflowScope()); this._initialScopes = this.CreateScopeSnapshot(); this.Engine = engine; - this.Evaluator = new WorkflowExpressionEngine(engine); + this.AllowsSideEffects = allowsSideEffects; + this.Evaluator = new WorkflowExpressionEngine(this); this.Bind(); } @@ -59,8 +65,38 @@ public FormulaValue Get(string variableName, string? scopeName = null) return FormulaValue.NewBlank(); } - public void Set(string variableName, FormulaValue value, string? scopeName = null) => - this.GetScope(scopeName ?? DefaultScopeName)[variableName] = value; + public void Set(string variableName, FormulaValue value, string? scopeName = null, SensitivityLevel sensitivity = SensitivityLevel.None) + { + WorkflowScope scope = this.GetScope(scopeName ?? DefaultScopeName); + scope[variableName] = value; + scope.Sensitivities[variableName] = sensitivity; + } + + public SensitivityLevel GetSensitivity(string variableName, string? scopeName = null) + { + if (scopeName is not null && !VariableScopeNames.IsValidName(scopeName)) + { + return SensitivityLevel.None; + } + + WorkflowScope scope = this.GetScope(scopeName ?? DefaultScopeName); + return scope.Sensitivities.TryGetValue(variableName, out SensitivityLevel sensitivity) ? sensitivity : SensitivityLevel.None; + } + + public SensitivityLevel GetScopeSensitivity(string scopeName) + { + if (!VariableScopeNames.IsValidName(scopeName)) + { + return SensitivityLevel.None; + } + + return this.GetScope(scopeName).Sensitivities.Values.Any(static sensitivity => sensitivity == SensitivityLevel.Sensitive) + ? SensitivityLevel.Sensitive + : SensitivityLevel.None; + } + + public void SetSensitivity(string variableName, string? scopeName, SensitivityLevel sensitivity) => + this.GetScope(scopeName ?? DefaultScopeName).Sensitivities[variableName] = sensitivity; public bool SetInitialized() => Interlocked.CompareExchange(ref this._isInitialized, 1, 0) == 0; @@ -96,13 +132,14 @@ async Task ReadScopeAsync(string scopeName) foreach (string key in keys) { PortableValue? value = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + SensitivityLevel sensitivity = await context.ReadStateAsync(key, GetSensitivityScopeName(scopeName), cancellationToken).ConfigureAwait(false); if (value is null) { - this.Set(key, FormulaValue.NewBlank(), scopeName); + this.Set(key, FormulaValue.NewBlank(), scopeName, sensitivity); continue; } FormulaValue formulaValue = value.ToFormula(); - this.Set(key, formulaValue, scopeName); + this.Set(key, formulaValue, scopeName, sensitivity); Debug.WriteLine($"RESTORED: {scopeName}.{key} => {formulaValue.Type}"); } @@ -119,10 +156,16 @@ private void RestoreInitialState() { WorkflowScope scope = this._scopes[initialScopeEntry.Key]; scope.Clear(); + scope.Sensitivities.Clear(); foreach (KeyValuePair initialValueEntry in initialScopeEntry.Value) { scope[initialValueEntry.Key] = initialValueEntry.Value; } + + foreach (KeyValuePair initialSensitivityEntry in initialScopeEntry.Value.Sensitivities) + { + scope.Sensitivities[initialSensitivityEntry.Key] = initialSensitivityEntry.Value; + } } } @@ -157,6 +200,8 @@ void Bind(string scopeName, string? targetScope = null) private WorkflowScope GetScope(string? scopeName) => this._scopes[GetScopeName(scopeName)]; + public static string GetSensitivityScopeName(string scopeName) => $"{SensitivityScopePrefix}{GetScopeName(scopeName)}"; + public static string GetScopeName(string? scopeName) { WorkflowDiagnostics.SetFoundryProduct(); @@ -185,6 +230,15 @@ public WorkflowScope() public WorkflowScope(IDictionary values) : base(values) { + if (values is WorkflowScope scope) + { + foreach (KeyValuePair sensitivity in scope.Sensitivities) + { + this.Sensitivities[sensitivity.Key] = sensitivity.Value; + } + } } + + public Dictionary Sensitivities { get; } = new(StringComparer.OrdinalIgnoreCase); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt index a36677538eb..8af74cb2369 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,2 +1,14 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt index a36677538eb..8af74cb2369 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,2 +1,14 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt index a36677538eb..8af74cb2369 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,2 +1,14 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt index a36677538eb..8af74cb2369 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,2 +1,14 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index a36677538eb..8af74cb2369 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,2 +1,14 @@ #nullable enable +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.get -> System.Collections.Generic.IEnumerable? +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowedEnvironmentVariables.init -> void +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.get -> bool +Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowOptions.AllowProcessEnvironmentVariableFallback.init -> void Microsoft.Agents.AI.Workflows.Declarative.Events.ExternalInputResponse.RequestId.get -> string? +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ConvertValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, Microsoft.Agents.AI.Workflows.Declarative.Kit.VariableType! targetType, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.EvaluateValueWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! expression, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! line, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.FormatTemplateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, System.Collections.Generic.IEnumerable! lines, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.QueueStateUpdateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, Microsoft.Agents.ObjectModel.Abstractions.EvaluationResult! value, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Microsoft.Agents.AI.Workflows.Declarative.Kit.IWorkflowContextExtensions.ReadStateWithSensitivityAsync(this Microsoft.Agents.AI.Workflows.IWorkflowContext! context, string! key, string? scopeName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 47167cd9e53..69b6c1e9bcc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -423,7 +423,7 @@ await Task.WhenAll(executorNotifyTask, restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false); this._lastCheckpointInfo = checkpointInfo; - this.StepTracer.Reload(this.StepTracer.StepNumber); + this.StepTracer.Reload(checkpoint.StepNumber); async ValueTask UpdateCheckpointIndexAsync() { diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs index 68e4af28786..b2e0db233a6 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs @@ -16,6 +16,8 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) public IConfiguration? Configuration { get; init; } + public IEnumerable? AllowedEnvironmentVariables { get; init; } + // Assign to continue an existing conversation public string? ConversationId { get; init; } @@ -46,6 +48,7 @@ public Workflow CreateWorkflow() new(agentProvider) { Configuration = this.Configuration, + AllowedEnvironmentVariables = this.AllowedEnvironmentVariables, ConversationId = this.ConversationId, LoggerFactory = this.LoggerFactory, McpToolHandler = this.McpToolHandler, diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs index 418a68e25e2..52cdbf30b30 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs @@ -4,6 +4,8 @@ using System.IO; using System.Linq; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -220,7 +222,7 @@ public void FromYaml_RemoteConnection() } [Fact] - public void FromYaml_WithVariableReferences() + public async Task FromYaml_WithVariableReferences() { // Arrange IConfiguration configuration = new ConfigurationBuilder() @@ -234,7 +236,10 @@ public void FromYaml_WithVariableReferences() .Build(); // Act - var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences, configuration); + var agent = AgentBotElementYaml.FromYaml( + PromptAgents.AgentWithVariableReferences, + configuration, + ["OpenAIEndpoint", "OpenAIApiKey", "Temperature", "TopP"]); // Assert Assert.NotNull(agent); @@ -242,16 +247,16 @@ public void FromYaml_WithVariableReferences() CurrentModels model = (agent.Model as CurrentModels)!; Assert.NotNull(model); Assert.NotNull(model.Options); - Assert.Equal(0.9, Eval(model.Options?.Temperature, configuration)); - Assert.Equal(0.8, Eval(model.Options?.TopP, configuration)); + Assert.Equal(0.9, await EvalAsync(model.Options?.Temperature, configuration, TestContext.Current.CancellationToken)); + Assert.Equal(0.8, await EvalAsync(model.Options?.TopP, configuration, TestContext.Current.CancellationToken)); Assert.NotNull(model.Connection); Assert.IsType(model.Connection); ApiKeyConnection connection = (model.Connection as ApiKeyConnection)!; Assert.NotNull(connection); Assert.NotNull(connection.Endpoint); Assert.NotNull(connection.Key); - Assert.Equal("endpoint", Eval(connection.Endpoint, configuration)); - Assert.Equal("apiKey", Eval(connection.Key, configuration)); + Assert.Equal("endpoint", await EvalAsync(connection.Endpoint, configuration, TestContext.Current.CancellationToken)); + Assert.Equal("apiKey", await EvalAsync(connection.Key, configuration, TestContext.Current.CancellationToken)); } /// @@ -270,7 +275,7 @@ public sealed class PersonInfo public string? Occupation { get; set; } } - private static string? Eval(StringExpression? expression, IConfiguration? configuration = null) + private static async Task EvalAsync(StringExpression? expression, IConfiguration? configuration = null, CancellationToken cancellationToken = default) { if (expression is null) { @@ -286,10 +291,10 @@ public sealed class PersonInfo } } - return expression.Eval(engine); + return await expression.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false); } - private static double? Eval(NumberExpression? expression, IConfiguration? configuration = null) + private static async Task EvalAsync(NumberExpression? expression, IConfiguration? configuration = null, CancellationToken cancellationToken = default) { if (expression is null) { @@ -305,6 +310,6 @@ public sealed class PersonInfo } } - return expression.Eval(engine); + return await expression.EvalAsync(engine, cancellationToken: cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs index 85906620005..914877e0f11 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs @@ -1,7 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerFx.Types; using Moq; namespace Microsoft.Agents.AI.Declarative.UnitTests.ChatClient; @@ -104,4 +110,188 @@ public async Task TryCreateAsync_Creates_ToolsAsync() var tools = chatClientAgent?.ChatOptions?.Tools; Assert.Equal(5, tools?.Count); } + + [Fact] + public async Task Constructor_WithNullFunctions_CreatesAgentAsync() + { + // Arrange + var promptAgent = PromptAgents.CreateTestPromptAgent(); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object, null); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + Assert.NotNull(agent); + } + + [Fact] + public async Task TryCreateAsync_WithOptions_LoadsAllowedConfigurationAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + ["OpenAIEndpoint"] = "https://example.openai.azure.com/", + ["OpenAIApiKey"] = "test-key", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + ChatClientPromptAgentFactory factory = new( + this._mockChatClient.Object, + null, + null, + configuration, + null, + allowedConfigurationVariables: ["Temperature", "TopP", "OpenAIEndpoint", "OpenAIApiKey"]); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + ChatClientAgent chatClientAgent = Assert.IsType(agent); + Assert.Equal(0.9F, chatClientAgent.ChatOptions?.Temperature); + Assert.Equal(0.8F, chatClientAgent.ChatOptions?.TopP); + } + + [Fact] + public async Task TryCreateAsync_WithLegacyConfiguration_ThrowsAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + ["OpenAIEndpoint"] = "https://example.openai.azure.com/", + ["OpenAIApiKey"] = "test-key", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object, configuration: configuration); + + // Act + var exception = await Assert.ThrowsAsync(async () => await factory.TryCreateAsync(promptAgent)); + + // Assert + Assert.Contains("Name isn't valid. 'Temperature' isn't recognized.", exception.Message); + } + + [Fact] + public async Task ProtectedConstructor_WithLegacyConfiguration_ThrowsAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + ["SOME_SECRET"] = "secret-value", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + LegacyInspectingPromptAgentFactory factory = new(configuration); + + // Act + await factory.TryCreateAsync(promptAgent); + var exception = await Assert.ThrowsAsync(async () => await factory.EvaluateAsync("Temperature")); + + // Assert + Assert.Contains("Name isn't valid. 'Temperature' isn't recognized.", exception.Message); + } + + [Fact] + public async Task CreateAsync_WithLegacyConfiguration_ThrowsCreateAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + CreateAsyncInspectingPromptAgentFactory factory = new(configuration, this._mockChatClient.Object); + + // Act + var exception = await Assert.ThrowsAsync(async () => await factory.CreateAsync(promptAgent)); + + // Assert + Assert.Contains("Name isn't valid. 'Temperature' isn't recognized.", exception.Message); + } + + [Fact] + public async Task TryCreateAsync_OnlyLoadsAllowedReferencedConfigurationAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Temperature"] = "0.9", + ["TopP"] = "0.8", + }) + .Build(); + GptComponentMetadata promptAgent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences); + InspectingPromptAgentFactory factory = new(configuration, ["Temperature"]); + + // Act + await factory.TryCreateAsync(promptAgent); + + // Assert + StringValue temperature = Assert.IsType(await factory.EvaluateAsync("Temperature")); + Assert.Equal("0.9", temperature.Value); + Assert.False(factory.CanEvaluate("TopP")); + } + + private sealed class InspectingPromptAgentFactory(IConfiguration configuration, IEnumerable allowedConfigurationVariables) + : PromptAgentFactory(engine: null, configuration: configuration, allowedConfigurationVariables: allowedConfigurationVariables) + { + public Task EvaluateAsync(string expression, CancellationToken cancellationToken = default) => this.Engine.EvalAsync(expression, cancellationToken); + + public bool CanEvaluate(string expression) => this.Engine.Check(expression).IsSuccess; + + public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + // Arrange + this.InitializeConfigurationVariables(promptAgent); + + // Act & Assert + return Task.FromResult(null); + } + } + + private sealed class CreateAsyncInspectingPromptAgentFactory(IConfiguration configuration, IChatClient chatClient) + : PromptAgentFactory(engine: null, configuration: configuration) + { + public string? TemperatureValue { get; private set; } + + public override async Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + // Arrange + StringValue temperature = Assert.IsType(await this.Engine.EvalAsync("Temperature", cancellationToken)); + + // Act + this.TemperatureValue = temperature.Value; + + // Assert + return new ChatClientAgent(chatClient); + } + } + + private sealed class LegacyInspectingPromptAgentFactory(IConfiguration configuration) + : PromptAgentFactory(engine: null, configuration: configuration) + { + public Task EvaluateAsync(string expression, CancellationToken cancellationToken = default) => this.Engine.EvalAsync(expression, cancellationToken); + + public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + // Arrange + this.InitializeConfigurationVariables(promptAgent); + + // Act & Assert + return Task.FromResult(null); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 7bb92f913c2..1f702d52858 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; @@ -11,6 +12,8 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerFx.Types; using Moq; using Xunit.Sdk; @@ -125,6 +128,196 @@ public async Task HostedWorkflowAgentIsolatesDeclarativeStateForImplicitSessions Assert.NotEqual(provider.MessageConversations[0], provider.MessageConversations[1]); } + [Fact] + public async Task Build_OnlyInitializesAllowedReferencedEnvironmentVariablesAsync() + { + // Arrange + const string AllowedName = "AllowedConfig"; + const string HiddenName = "HiddenConfig"; + const string ProcessOnlyName = "ProcessOnlyConfig"; + const string ProcessOnlyValue = "process-value"; + + string? originalProcessOnlyValue = Environment.GetEnvironmentVariable(ProcessOnlyName); + Environment.SetEnvironmentVariable(ProcessOnlyName, ProcessOnlyValue); + + try + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AllowedName] = "allowed-value", + [HiddenName] = "hidden-value", + }) + .Build(); + using StringReader yamlReader = new( + """ + kind: Workflow + trigger: + + kind: OnConversationStart + id: env_boundary_workflow + actions: + + - kind: ConditionGroup + id: environment_boundary_condition + conditions: + - id: environment_boundary_passed + condition: =Env.AllowedConfig = "allowed-value" + actions: + - kind: SendActivity + id: environment_boundary_passed_activity + activity: allowed-configuration-available + elseActions: + - kind: SendActivity + id: environment_boundary_failed_activity + activity: allowed-configuration-missing + + - kind: SetVariable + id: referenced_hidden_configuration + disabled: true + variable: Local.Hidden + value: =Env.HiddenConfig + + - kind: SetVariable + id: referenced_process_configuration + disabled: true + variable: Local.ProcessOnly + value: =Env.ProcessOnlyConfig + """); + Mock mockAgentProvider = CreateMockProvider("Test input message"); + DeclarativeWorkflowOptions options = + new(mockAgentProvider.Object) + { + Configuration = configuration, + AllowedEnvironmentVariables = [AllowedName, ProcessOnlyName], + LoggerFactory = this.Output, + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, options); + WorkflowFormulaState rootState = GetRootState(workflow); + + // Act + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "Test input message"); + + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) + { + this.WorkflowEvents.Add(workflowEvent); + if (workflowEvent is WorkflowErrorEvent errorEvent) + { + throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure..."); + } + } + + // Assert + StringValue allowedValue = Assert.IsType(rootState.Get(AllowedName, VariableScopeNames.Environment)); + Assert.Equal("allowed-value", allowedValue.Value); + Assert.IsType(rootState.Get(HiddenName, VariableScopeNames.Environment)); + Assert.IsType(rootState.Get(ProcessOnlyName, VariableScopeNames.Environment)); + this.AssertMessage("allowed-configuration-available"); + this.AssertNotMessage("allowed-configuration-missing"); + } + finally + { + Environment.SetEnvironmentVariable(ProcessOnlyName, originalProcessOnlyValue); + } + } + + [Fact] + public async Task Build_WithProcessEnvironmentFallback_LoadsAllowedMissingConfigurationFromProcessEnvironmentAsync() + { + // Arrange + const string ProcessOnlyName = "ProcessOnlyConfigForFallback"; + const string ExplicitName = "ExplicitConfigWinsForFallback"; + const string HiddenName = "HiddenConfigForFallback"; + const string ProcessOnlyValue = "process-only-value"; + const string ExplicitConfigurationValue = "configuration-value"; + const string ExplicitProcessValue = "process-value"; + const string HiddenValue = "hidden-value"; + + string? originalProcessOnlyValue = Environment.GetEnvironmentVariable(ProcessOnlyName); + string? originalExplicitValue = Environment.GetEnvironmentVariable(ExplicitName); + string? originalHiddenValue = Environment.GetEnvironmentVariable(HiddenName); + Environment.SetEnvironmentVariable(ProcessOnlyName, ProcessOnlyValue); + Environment.SetEnvironmentVariable(ExplicitName, ExplicitProcessValue); + Environment.SetEnvironmentVariable(HiddenName, HiddenValue); + + try + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [ExplicitName] = ExplicitConfigurationValue, + }) + .Build(); + using StringReader yamlReader = new( + """ + kind: Workflow + trigger: + + kind: OnConversationStart + id: env_fallback_workflow + actions: + + - kind: ConditionGroup + id: environment_fallback_condition + conditions: + - id: environment_fallback_passed + condition: =Env.ProcessOnlyConfigForFallback = "process-only-value" && Env.ExplicitConfigWinsForFallback = "configuration-value" + actions: + - kind: SendActivity + id: environment_fallback_passed_activity + activity: process-environment-fallback-enabled + elseActions: + - kind: SendActivity + id: environment_fallback_failed_activity + activity: process-environment-fallback-failed + + - kind: SetVariable + id: referenced_hidden_process_environment + disabled: true + variable: Local.Hidden + value: =Env.HiddenConfigForFallback + """); + Mock mockAgentProvider = CreateMockProvider("Test input message"); + DeclarativeWorkflowOptions options = + new(mockAgentProvider.Object) + { + Configuration = configuration, + AllowedEnvironmentVariables = [ProcessOnlyName, ExplicitName], + AllowProcessEnvironmentVariableFallback = true, + LoggerFactory = this.Output, + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, options); + WorkflowFormulaState rootState = GetRootState(workflow); + + // Act + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "Test input message"); + + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) + { + this.WorkflowEvents.Add(workflowEvent); + if (workflowEvent is WorkflowErrorEvent errorEvent) + { + throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure..."); + } + } + + // Assert + StringValue processOnlyValue = Assert.IsType(rootState.Get(ProcessOnlyName, VariableScopeNames.Environment)); + Assert.Equal(ProcessOnlyValue, processOnlyValue.Value); + StringValue explicitValue = Assert.IsType(rootState.Get(ExplicitName, VariableScopeNames.Environment)); + Assert.Equal(ExplicitConfigurationValue, explicitValue.Value); + Assert.IsType(rootState.Get(HiddenName, VariableScopeNames.Environment)); + this.AssertMessage("process-environment-fallback-enabled"); + this.AssertNotMessage("process-environment-fallback-failed"); + } + finally + { + Environment.SetEnvironmentVariable(ProcessOnlyName, originalProcessOnlyValue); + Environment.SetEnvironmentVariable(ExplicitName, originalExplicitValue); + Environment.SetEnvironmentVariable(HiddenName, originalHiddenValue); + } + } + [Fact] public async Task GotoActionAsync() { @@ -372,6 +565,17 @@ private void AssertExecuted(string executorId, bool isAction = true, bool isDisc private void AssertMessage(string message) => Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Message.Trim(), message, StringComparison.Ordinal)); + private void AssertNotMessage(string message) => + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => string.Equals(e.Message.Trim(), message, StringComparison.Ordinal)); + + private static WorkflowFormulaState GetRootState(Workflow workflow) + { + ExecutorBinding rootBinding = workflow.ReflectExecutors()[workflow.StartExecutorId]; + Executor rootExecutor = Assert.IsAssignableFrom(rootBinding.RawValue); + FieldInfo stateField = Assert.Single(rootExecutor.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic), field => field.FieldType == typeof(WorkflowFormulaState)); + return Assert.IsType(stateField.GetValue(rootExecutor)); + } + private Task RunWorkflowAsync(string workflowPath) => this.RunWorkflowAsync(workflowPath, "Test input message"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs new file mode 100644 index 00000000000..d26a1b6a84b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/IWorkflowContextExtensionsTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +public sealed class IWorkflowContextExtensionsTests +{ + [Fact] + public async Task FormatTemplateAsync_WithSensitiveValue_ThrowsAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + ValueTask FormatAsync() => context.FormatTemplateAsync("={Env.SOME_SECRET}"); + + // Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(async () => await FormatAsync()); + Assert.Contains("Cannot return sensitive workflow expression value", exception.Message); + } + + [Fact] + public async Task FormatTemplateWithSensitivityAsync_WithSensitiveValue_ReturnsSensitivityAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + EvaluationResult result = await context.FormatTemplateWithSensitivityAsync("={Env.SOME_SECRET}"); + + // Assert + Assert.Equal("=secret-value" + System.Environment.NewLine, result.Value); + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + } + + [Fact] + public async Task EvaluateValueAsync_WithSensitiveValue_ThrowsAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set(SystemScope.Names.LastMessageText, FormulaValue.New("secret-value"), VariableScopeNames.System, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + ValueTask EvaluateAsync() => context.EvaluateValueAsync("System.LastMessageText"); + + // Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(async () => await EvaluateAsync()); + Assert.Contains("Cannot return sensitive workflow expression value", exception.Message); + } + + [Fact] + public async Task EvaluateValueWithSensitivityAsync_WithSensitiveValue_ReturnsSensitivityAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set(SystemScope.Names.LastMessageText, FormulaValue.New("secret-value"), VariableScopeNames.System, SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + EvaluationResult result = await context.EvaluateValueWithSensitivityAsync("System.LastMessageText"); + + // Assert + Assert.Equal("secret-value", result.Value); + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + } + + [Fact] + public async Task QueueStateUpdateAsync_WithSensitivity_RebindsStateAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set("TestValue", FormulaValue.New("old-value")); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + await context.QueueStateUpdateAsync(PropertyPath.Create("Local.TestValue"), FormulaValue.New("new-value"), SensitivityLevel.Sensitive); + + // Assert + Assert.Equal("new-value", state.Engine.Eval("Local.TestValue").ToObject()); + Assert.Equal(SensitivityLevel.Sensitive, state.GetSensitivity("TestValue", VariableScopeNames.Local)); + } + + [Fact] + public async Task ReadStateWithSensitivityAsync_QueuesSensitiveAssignmentAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set(SystemScope.Names.LastMessageText, FormulaValue.New("secret-value"), VariableScopeNames.System, SensitivityLevel.Sensitive); + state.Bind(); + + Mock source = new(MockBehavior.Loose); + source + .Setup(c => c.ReadStateAsync(SystemScope.Names.LastMessageText, VariableScopeNames.System, default)) + .Returns(new ValueTask("secret-value")); + DeclarativeWorkflowContext context = new(source.Object, state); + + // Act + var evaluatedValue = await context.ReadStateWithSensitivityAsync(SystemScope.Names.LastMessageText, VariableScopeNames.System); + await context.QueueStateUpdateWithSensitivityAsync("TestValue", evaluatedValue, VariableScopeNames.Local); + + // Assert + Assert.Equal("secret-value", state.Engine.Eval("Local.TestValue").ToObject()); + Assert.Equal(SensitivityLevel.Sensitive, state.GetSensitivity("TestValue", VariableScopeNames.Local)); + } + + [Fact] + public async Task ReadStateWithSensitivityAsync_WithPlainContext_ReadsSensitivitySidecarAsync() + { + // Arrange + Mock context = new(MockBehavior.Loose); + context + .Setup(c => c.ReadStateAsync("TestValue", VariableScopeNames.Local, default)) + .Returns(new ValueTask("secret-value")); + context + .Setup(c => c.ReadStateAsync("TestValue", WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), default)) + .Returns(new ValueTask(SensitivityLevel.Sensitive)); + + // Act + var evaluatedValue = await context.Object.ReadStateWithSensitivityAsync("TestValue", VariableScopeNames.Local); + + // Assert + Assert.Equal("secret-value", evaluatedValue.Value); + Assert.Equal(SensitivityLevel.Sensitive, evaluatedValue.Sensitivity); + } + + [Fact] + public async Task QueueStateUpdateWithSensitivityAsync_WithPlainContext_QueuesSensitivitySidecarAsync() + { + // Arrange + Mock context = new(MockBehavior.Strict); + context + .Setup(c => c.QueueStateUpdateAsync("TestValue", "secret-value", VariableScopeNames.Local, default)) + .Returns(default(ValueTask)); + context + .Setup(c => c.QueueStateUpdateAsync("TestValue", SensitivityLevel.Sensitive, WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), default)) + .Returns(default(ValueTask)); + + // Act + await context.Object.QueueStateUpdateWithSensitivityAsync("TestValue", new EvaluationResult("secret-value", SensitivityLevel.Sensitive), VariableScopeNames.Local); + + // Assert + context.VerifyAll(); + } + + [Fact] + public async Task GeneratedForeachPattern_WithSensitiveCollection_PreservesItemSensitivityAsync() + { + // Arrange + WorkflowFormulaState state = new(RecalcEngineFactory.Create()); + state.Set( + "SensitiveItems", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("first"))), + FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("second")))), + VariableScopeNames.Environment, + SensitivityLevel.Sensitive); + state.Bind(); + DeclarativeWorkflowContext context = new(new Mock().Object, state); + + // Act + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Env.SensitiveItems"); + IEnumerable values = Assert.IsAssignableFrom(evaluatedValue.Value); + object? firstValue = values.Cast().First(); + await context.QueueStateUpdateWithSensitivityAsync( + key: "LoopValue", + value: new EvaluationResult(firstValue, evaluatedValue.Sensitivity), + scopeName: VariableScopeNames.Local); + + // Assert + Assert.Equal(SensitivityLevel.Sensitive, evaluatedValue.Sensitivity); + Assert.NotNull(state.Get("LoopValue").ToObject()); + Assert.Equal(SensitivityLevel.Sensitive, state.GetSensitivity("LoopValue")); + } + + [Fact] + public async Task ConvertValueWithSensitivityAsync_WithPlainContext_PreservesSensitivitySidecarAsync() + { + // Arrange + Mock context = new(MockBehavior.Loose); + context + .Setup(c => c.ReadStateAsync("TestValue", VariableScopeNames.Local, default)) + .Returns(new ValueTask(new PortableValue("42"))); + context + .Setup(c => c.ReadStateAsync("TestValue", WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), default)) + .Returns(new ValueTask(SensitivityLevel.Sensitive)); + + // Act + EvaluationResult result = await context.Object.ConvertValueWithSensitivityAsync(typeof(decimal), "TestValue", VariableScopeNames.Local); + + // Assert + Assert.Equal(42M, result.Value); + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/RootExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/RootExecutorTests.cs new file mode 100644 index 00000000000..4fedeb6d0f5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/RootExecutorTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.Configuration; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +public sealed class RootExecutorTests +{ + [Fact] + public async Task InitializeEnvironmentAsync_OnlyQueuesAllowedVariablesAsync() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ALLOWED"] = "allowed-value", + ["HIDDEN"] = "hidden-value", + }) + .Build(); + DeclarativeWorkflowOptions options = + new(new MockAgentProvider().Object) + { + Configuration = configuration, + AllowedEnvironmentVariables = ["ALLOWED"], + }; + TestRootExecutor executor = new(options); + Mock sourceContext = new(MockBehavior.Strict); + sourceContext.Setup(c => c.QueueStateUpdateAsync("ALLOWED", It.IsAny(), VariableScopeNames.Environment, It.IsAny())) + .Returns(default(ValueTask)); + sourceContext.Setup(c => c.QueueStateUpdateAsync("ALLOWED", SensitivityLevel.Sensitive, WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Environment), It.IsAny())) + .Returns(default(ValueTask)); + + DeclarativeWorkflowContext context = new(sourceContext.Object, executor.Session.State); + + // Act + await executor.InitializeAsync(context, "ALLOWED", "HIDDEN"); + + // Assert + sourceContext.Verify(c => c.QueueStateUpdateAsync("ALLOWED", It.IsAny(), VariableScopeNames.Environment, It.IsAny()), Times.Once); + sourceContext.Verify(c => c.QueueStateUpdateAsync("ALLOWED", SensitivityLevel.Sensitive, WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Environment), It.IsAny()), Times.Once); + sourceContext.Verify(c => c.QueueStateUpdateAsync("HIDDEN", It.IsAny(), VariableScopeNames.Environment, It.IsAny()), Times.Never); + } + + private sealed class TestRootExecutor(DeclarativeWorkflowOptions options) : RootExecutor("test_root", options, inputTransform: null) + { + public ValueTask InitializeAsync(IWorkflowContext context, params string[] variableNames) => + this.InitializeEnvironmentAsync(context, variableNames); + + protected override ValueTask ExecuteAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + default; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs index 2f89de4dee5..abef7277cb0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs @@ -70,6 +70,62 @@ await this.ExecuteTestAsync( metadata: metadataRecord); } + [Fact] + public async Task AddMessageWithSensitiveContentThrowsAsync() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + MockAgentProvider mockAgentProvider = new(); + int messageCount = mockAgentProvider.TestMessages.Count; + AddConversationMessage model = + this.CreateModel( + this.FormatDisplayName(nameof(AddMessageWithSensitiveContentThrowsAsync)), + FormatVariablePath("TestMessage"), + "TestConversationId", + AgentMessageRoleWrapper.Get(AgentMessageRole.User), + "={Env.SOME_SECRET}", + metadata: null); + + AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive conversation message content", exception.Message); + Assert.Equal(messageCount, mockAgentProvider.TestMessages.Count); + } + + [Fact] + public async Task AddMessageWithSensitiveMetadataThrowsAsync() + { + // Arrange + Dictionary metadataValues = + new() + { + ["Key1"] = "secret-value", + }; + this.State.Set("SecretMetadata", metadataValues.ToRecordValue().ToFormula(), sensitivity: SensitivityLevel.Sensitive); + MockAgentProvider mockAgentProvider = new(); + int messageCount = mockAgentProvider.TestMessages.Count; + AddConversationMessage model = + this.CreateModel( + this.FormatDisplayName(nameof(AddMessageWithSensitiveMetadataThrowsAsync)), + FormatVariablePath("TestMessage"), + "TestConversationId", + AgentMessageRoleWrapper.Get(AgentMessageRole.User), + "Hello", + metadata: null, + ObjectExpression.Variable(PropertyPath.TopicVariable("SecretMetadata")).ToBuilder()); + + AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive conversation message metadata", exception.Message); + Assert.Equal(messageCount, mockAgentProvider.TestMessages.Count); + } + private async Task ExecuteTestAsync( string displayName, string variableName, @@ -112,10 +168,10 @@ private AddConversationMessage CreateModel( string conversationId, AgentMessageRoleWrapper role, string messageText, - RecordDataValue? metadata) + RecordDataValue? metadata, + ObjectExpression.Builder? metadataExpression = null) { - ObjectExpression.Builder? metadataExpression = null; - if (metadata is not null) + if (metadata is not null && metadataExpression is null) { metadataExpression = ObjectExpression.Literal(metadata).ToBuilder(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs index c0a2fdf6598..67327f87a2f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs @@ -86,6 +86,32 @@ await this.ExecuteTestAsync( expectedMessageCount: 1); } + [Fact] + public async Task CopyMessagesWithSensitiveVariableThrowsAsync() + { + // Arrange + List testMessages = + [ + new ChatMessage(ChatRole.User, "Message from variable") + ]; + TableValue messagesTable = testMessages.ToTable(); + this.State.Set("SourceMessages", messagesTable, sensitivity: SensitivityLevel.Sensitive); + MockAgentProvider mockAgentProvider = new(); + int messageCount = mockAgentProvider.TestMessages.Count; + CopyConversationMessages model = this.CreateModel( + this.FormatDisplayName(nameof(CopyMessagesWithSensitiveVariableThrowsAsync)), + "TestConversationId", + ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages"))); + + CopyConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive conversation messages", exception.Message); + Assert.Equal(messageCount, mockAgentProvider.TestMessages.Count); + } + [Fact] public async Task CopyMessagesToWorkflowConversationAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs index c861d77eace..35b5f2d92e4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeAzureAgentExecutorTest.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; @@ -139,6 +140,32 @@ public async Task RecordValuedArgumentIsBoundAsRecordAsync() Assert.Equal("beta", record["b"]); } + [Fact] + public async Task SensitiveInputMessagesThrowAsync() + { + // Arrange + this.State.InitializeSystem(); + List testMessages = + [ + new ChatMessage(ChatRole.User, "Message from variable") + ]; + this.State.Set("SourceMessages", testMessages.ToTable(), sensitivity: SensitivityLevel.Sensitive); + CapturingAgentProvider provider = new("acknowledged"); + InvokeAzureAgent model = + this.CreateModel( + displayName: nameof(SensitiveInputMessagesThrowAsync), + agentName: "BrainMessages", + messages: ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages"))); + + InvokeAzureAgentExecutor action = new(model, provider, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action, isDiscrete: false); + + // Act & Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive agent input messages", exception.Message); + Assert.Null(provider.CapturedMessages); + } + [Fact] public async Task InlineRecordExpressionArgumentIsBoundAsRecordAsync() { @@ -329,6 +356,7 @@ private InvokeAzureAgent CreateModel( string agentName, long? agentVersion = null, IReadOnlyList<(string Key, ValueExpression Value)>? arguments = null, + ValueExpression? messages = null, string? responseObjectVariable = null) { InvokeAzureAgent.Builder builder = @@ -358,6 +386,11 @@ private InvokeAzureAgent CreateModel( builder.Input = inputBuilder; } + if (messages is not null) + { + (builder.Input ??= new AzureAgentInput.Builder()).Messages = messages; + } + if (responseObjectVariable is not null) { builder.Output = @@ -405,6 +438,8 @@ private sealed class CapturingAgentProvider(string responseText) : ResponseAgent public IDictionary? CapturedArguments { get; private set; } + public IEnumerable? CapturedMessages { get; private set; } + public override IAsyncEnumerable InvokeAgentAsync( string agentId, string? agentVersion, @@ -415,6 +450,7 @@ public override IAsyncEnumerable InvokeAgentAsync( { this.CapturedAgentVersion = agentVersion; this.CapturedArguments = inputArguments; + this.CapturedMessages = messages; return YieldAsync(responseText); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs index dbe056f891e..b5cd92c4c2c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/QuestionExecutorTest.cs @@ -228,6 +228,27 @@ await this.CaptureResponseTestAsync( expectResponse: false); } + [Fact] + public async Task QuestionCaptureResponseExceedingRepeatCountPreservesDefaultValueSensitivityAsync() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + Question model = this.CreateModel( + displayName: nameof(QuestionCaptureResponseExceedingRepeatCountPreservesDefaultValueSensitivityAsync), + variableName: "TestVariable", + repeatCount: 0, + defaultValueExpressionText: "Env.SOME_SECRET"); + + // Act & Assert + await this.CaptureResponseTestAsync( + model, + variableName: "TestVariable", + responseText: null, + expectResponse: false); + Assert.Equal(SensitivityLevel.Sensitive, this.State.GetSensitivity("TestVariable")); + } + [Fact] public async Task QuestionCaptureResponseWithAutoSendFalseAsync() { @@ -438,7 +459,8 @@ private Question CreateModel( SkipQuestionMode? skipMode = null, int? repeatCount = null, EntityReference? entity = null, - DataValue? autoSend = null) + DataValue? autoSend = null, + string? defaultValueExpressionText = null) { BoolExpression.Builder? alwaysPromptExpression = null; if (alwaysPrompt is not null) @@ -457,6 +479,10 @@ private Question CreateModel( { defaultValueExpression = ValueExpression.Literal(defaultValue).ToBuilder(); } + else if (defaultValueExpressionText is not null) + { + defaultValueExpression = ValueExpression.Expression(defaultValueExpressionText).ToBuilder(); + } EnumExpression.Builder? skipModeExpression = null; if (skipMode is not null) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs index bfddd1b8f0d..4a12f4528f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -5,6 +5,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; @@ -43,6 +44,25 @@ public async Task CaptureActivityAsync() Assert.Equal(message.MessageId, updateEvent.Update.MessageId); } + [Fact] + public async Task CaptureActivity_WithSensitiveEnvironmentValue_ThrowsAsync() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + SendActivity model = + this.CreateModel( + this.FormatDisplayName(nameof(CaptureActivity_WithSensitiveEnvironmentValue_ThrowsAsync)), + "={Env.SOME_SECRET}"); + + // Act + SendActivityExecutor action = new(model, this.State); + Task ExecuteAsync() => this.ExecuteAsync(action); + + // Assert + DeclarativeActionException exception = await Assert.ThrowsAsync(ExecuteAsync); + Assert.Contains("Cannot send sensitive activity text", exception.Message); + } + private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) { MessageActivityTemplate.Builder activityBuilder = diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs index d158ca552b2..cb081a7ba50 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx; @@ -32,16 +33,17 @@ public void NewInstanceEachTime() } [Fact] - public void HasSetFunctionEnabled() + public void SetFunctionDisabledByDefault() { // Arrange RecalcEngine engine = RecalcEngineFactory.Create(); + engine.UpdateVariable("MyVariable", FormulaValue.New(0)); // Act - CheckResult result = engine.Check("1+1"); + CheckResult result = engine.Check("Set(MyVariable, 1)", options: new ParserOptions() { AllowsSideEffects = true }); // Assert - Assert.True(result.IsSuccess); + Assert.False(result.IsSuccess); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index ebaaf5d0466..87054d436f7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -123,6 +123,80 @@ public void StringExpressionGetValueForVariable() expectedValue: "Hello World"); } + [Fact] + public void StringExpressionGetValueForEnvironmentVariableIsSensitive() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.Create("Env.SOME_SECRET")), + expectedValue: "secret-value", + expectedSensitivity: SensitivityLevel.Sensitive); + } + + [Fact] + public void StringExpressionGetValueForQuotedEnvironmentVariableIsSensitive() + { + // Arrange + this.State.Set("API-KEY", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Expression("Env.'API-KEY'"), + expectedValue: "secret-value", + expectedSensitivity: SensitivityLevel.Sensitive); + } + + [Fact] + public void ValueExpressionGetValueForEnvironmentScopeIsSensitive() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Set("PUBLIC_VALUE", FormulaValue.New("public-value"), VariableScopeNames.Environment); + this.State.Bind(); + + // Act + EvaluationResult result = this.State.Evaluator.GetValue(ValueExpression.Expression("Env")); + + // Assert + Assert.Equal(SensitivityLevel.Sensitive, result.Sensitivity); + Assert.NotNull(result.Value.ToObject()); + } + + [Fact] + public void StringExpressionGetValueForComputedDottedAccessIsSensitive() + { + // Arrange + TableValue secretTable = FormulaValue.NewTable( + RecordType.Empty().Add("Value", FormulaType.String), + new RecordValue[] { FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("secret-value"))) }); + this.State.Set("SecretTable", secretTable, VariableScopeNames.Local, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Expression("First(Local.SecretTable).Value"), + expectedValue: "secret-value", + expectedSensitivity: SensitivityLevel.Sensitive); + } + + [Fact] + public void StringExpressionGetValueForEnvironmentVariableTextLiteralIsNotSensitive() + { + // Arrange + this.State.Set("SOME_SECRET", FormulaValue.New("secret-value"), VariableScopeNames.Environment, SensitivityLevel.Sensitive); + this.State.Bind(); + + // Act & Assert + this.EvaluateExpression( + StringExpression.Expression(@"Concatenate(""Env.SOME_SECRET"", "" literal"")"), + expectedValue: "Env.SOME_SECRET literal"); + } + [Fact] public void StringExpressionGetValueForFormula() => // Arrange, Act & Assert diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs index 5193296db4d..0af699c501c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowFormulaStateTests.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; +using Moq; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx; @@ -81,4 +85,23 @@ public void SetOverwritesExistingValue() FormulaValue result = this.State.Get("key1"); Assert.Equal(newValue, result); } + + [Fact] + public async Task RestoreAsync_RestoresPersistedSensitivityAsync() + { + // Arrange + Mock context = new(MockBehavior.Strict); + context.Setup(c => c.ReadStateKeysAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string? scopeName, CancellationToken _) => scopeName == VariableScopeNames.Local ? new HashSet { "secret" } : []); + context.Setup(c => c.ReadStateAsync("secret", VariableScopeNames.Local, It.IsAny())) + .ReturnsAsync(new PortableValue("secret-value")); + context.Setup(c => c.ReadStateAsync("secret", WorkflowFormulaState.GetSensitivityScopeName(VariableScopeNames.Local), It.IsAny())) + .ReturnsAsync(SensitivityLevel.Sensitive); + + // Act + await this.State.RestoreAsync(context.Object, CancellationToken.None); + + // Assert + Assert.Equal(SensitivityLevel.Sensitive, this.State.GetSensitivity("secret")); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs index 3dfa7b4f68a..04eb0196bc5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/Condition.cs @@ -56,8 +56,8 @@ internal sealed class SetvariableTestExecutor(FormulaSession session) : ActionEx // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Value(System.LastMessageText)").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Value(System.LastMessageText)").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } @@ -71,14 +71,14 @@ internal sealed class ConditiongroupTestExecutor(FormulaSession session) : Actio // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); - if (condition0) + var condition0 = await context.EvaluateValueWithSensitivityAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); + if (condition0.Value) { return "conditionItem_odd"; } - bool condition1 = await context.EvaluateValueAsync("Mod(Local.TestValue, 2) = 0").ConfigureAwait(false); - if (condition1) + var condition1 = await context.EvaluateValueWithSensitivityAsync("Mod(Local.TestValue, 2) = 0").ConfigureAwait(false); + if (condition1.Value) { return "conditionItem_even"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs index 2f64bdd3e54..23ccaede2b8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ConditionElse.cs @@ -56,8 +56,8 @@ internal sealed class SetvariableTestExecutor(FormulaSession session) : ActionEx // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Value(System.LastMessageText)").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Value(System.LastMessageText)").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } @@ -71,8 +71,8 @@ internal sealed class ConditiongroupTestExecutor(FormulaSession session) : Actio // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); - if (condition0) + var condition0 = await context.EvaluateValueWithSensitivityAsync("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false); + if (condition0.Value) { return "conditionItem_odd"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs index 0c612dcae99..5b2fb4eecf2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTable.cs @@ -56,8 +56,8 @@ internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("[{id: 3}]").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("[{id: 3}]").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs index 0c612dcae99..5b2fb4eecf2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/EditTableV2.cs @@ -56,8 +56,8 @@ internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("[{id: 3}]").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("[{id: 3}]").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs index 04459394b09..26525b6b710 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs @@ -17,6 +17,8 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Test.WorkflowProviders; @@ -55,8 +57,14 @@ protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext /// internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session) { + private const string IndexStateKey = nameof(_index); + private const string ValuesStateKey = nameof(_values); + private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); + private int _index; - private object[] _values = []; + private PortableValue[] _values = []; + private SensitivityLevel _sensitivity; public bool HasValue { get; private set; } @@ -64,22 +72,23 @@ internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecut protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; - object? evaluatedValue = await context.EvaluateValueAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); - if (evaluatedValue == null) + if (evaluatedValue.Value == null) { this._values = []; this.HasValue = false; } else - if (evaluatedValue is IEnumerable evaluatedList) + if (evaluatedValue.Value is IEnumerable evaluatedList) { - this._values = [.. evaluatedList]; + this._values = [.. evaluatedList.Cast().Select(ToPortableValue)]; } else { - this._values = [evaluatedValue]; + this._values = [ToPortableValue(evaluatedValue.Value)]; } + this._sensitivity = evaluatedValue.Sensitivity; await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); @@ -90,10 +99,10 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { if (this.HasValue = this._index < this._values.Length) { - object value = this._values[this._index]; + object? value = this._values[this._index].NormalizePortableValue(); - await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "LoopValue", value: new EvaluationResult(value, this._sensitivity), scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); this._index++; } @@ -109,6 +118,36 @@ private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken c await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); } + + protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(ValuesStateKey, this._values, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + PortableValue[]? savedValues = + await context.ReadStateAsync(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (savedValues is null) + { + return; + } + + this._values = savedValues; + this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + + private static PortableValue ToPortableValue(object? value) => + new(value ?? UnassignedValue.Instance); } /// @@ -119,8 +158,8 @@ internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionE // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.Count + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Local.Count + 1").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs index d023184476a..4e6f09438a7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs @@ -17,6 +17,8 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Test.WorkflowProviders; @@ -55,8 +57,14 @@ protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext /// internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session) { + private const string IndexStateKey = nameof(_index); + private const string ValuesStateKey = nameof(_values); + private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); + private int _index; - private object[] _values = []; + private PortableValue[] _values = []; + private SensitivityLevel _sensitivity; public bool HasValue { get; private set; } @@ -64,22 +72,23 @@ internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecut protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; - object? evaluatedValue = await context.EvaluateValueAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); - if (evaluatedValue == null) + if (evaluatedValue.Value == null) { this._values = []; this.HasValue = false; } else - if (evaluatedValue is IEnumerable evaluatedList) + if (evaluatedValue.Value is IEnumerable evaluatedList) { - this._values = [.. evaluatedList]; + this._values = [.. evaluatedList.Cast().Select(ToPortableValue)]; } else { - this._values = [evaluatedValue]; + this._values = [ToPortableValue(evaluatedValue.Value)]; } + this._sensitivity = evaluatedValue.Sensitivity; await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); @@ -90,10 +99,10 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { if (this.HasValue = this._index < this._values.Length) { - object value = this._values[this._index]; + object? value = this._values[this._index].NormalizePortableValue(); - await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "LoopValue", value: new EvaluationResult(value, this._sensitivity), scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); this._index++; } @@ -109,6 +118,36 @@ private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken c await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); } + + protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(ValuesStateKey, this._values, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + PortableValue[]? savedValues = + await context.ReadStateAsync(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (savedValues is null) + { + return; + } + + this._values = savedValues; + this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + + private static PortableValue ToPortableValue(object? value) => + new(value ?? UnassignedValue.Instance); } /// @@ -119,8 +158,8 @@ internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionE // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.Count + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Local.Count + 1").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs index a467d42f338..ffacf0fbd19 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs @@ -17,6 +17,8 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.ObjectModel; +using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.AI; namespace Test.WorkflowProviders; @@ -55,8 +57,14 @@ protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext /// internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session) { + private const string IndexStateKey = nameof(_index); + private const string ValuesStateKey = nameof(_values); + private const string HasValueStateKey = nameof(HasValue); + private const string SensitivityStateKey = nameof(_sensitivity); + private int _index; - private object[] _values = []; + private PortableValue[] _values = []; + private SensitivityLevel _sensitivity; public bool HasValue { get; private set; } @@ -64,22 +72,23 @@ internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecut protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; - object? evaluatedValue = await context.EvaluateValueAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); + EvaluationResult evaluatedValue = await context.EvaluateValueWithSensitivityAsync("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false); - if (evaluatedValue == null) + if (evaluatedValue.Value == null) { this._values = []; this.HasValue = false; } else - if (evaluatedValue is IEnumerable evaluatedList) + if (evaluatedValue.Value is IEnumerable evaluatedList) { - this._values = [.. evaluatedList]; + this._values = [.. evaluatedList.Cast().Select(ToPortableValue)]; } else { - this._values = [evaluatedValue]; + this._values = [ToPortableValue(evaluatedValue.Value)]; } + this._sensitivity = evaluatedValue.Sensitivity; await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); @@ -90,10 +99,10 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { if (this.HasValue = this._index < this._values.Length) { - object value = this._values[this._index]; + object? value = this._values[this._index].NormalizePortableValue(); - await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "LoopValue", value: new EvaluationResult(value, this._sensitivity), scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false); this._index++; } @@ -109,6 +118,36 @@ private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken c await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); } + + protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(ValuesStateKey, this._values, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(SensitivityStateKey, this._sensitivity, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + PortableValue[]? savedValues = + await context.ReadStateAsync(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (savedValues is null) + { + return; + } + + this._values = savedValues; + this._index = await context.ReadStateAsync(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this.HasValue = await context.ReadStateAsync(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._sensitivity = await context.ReadStateAsync(SensitivityStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + + private static PortableValue ToPortableValue(object? value) => + new(value ?? UnassignedValue.Instance); } /// @@ -119,8 +158,8 @@ internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionE // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.Count + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("Local.Count + 1").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs index 55d4ba1efb3..d254ab9498c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValue.cs @@ -73,8 +73,8 @@ internal sealed class ParseVarExecutor(FormulaSession session) : ActionExecutor( protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { VariableType targetType = typeof(decimal); - object? parsedValue = await context.ConvertValueAsync(targetType, key: "MySource", scopeName: "Local", cancellationToken).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "MyVar", value: parsedValue, scopeName: "Local").ConfigureAwait(false); + var parsedValue = await context.ConvertValueWithSensitivityAsync(targetType, key: "MySource", scopeName: "Local", cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "MyVar", value: parsedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs index 05cd29c5744..b092f100023 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SendActivity.cs @@ -56,8 +56,8 @@ internal sealed class SetInputExecutor(FormulaSession session) : ActionExecutor( // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.ReadStateAsync(key: "LastMessageText", scopeName: "System").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.ReadStateWithSensitivityAsync(key: "LastMessageText", scopeName: "System").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs index 98b3bf23511..20157631ee7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.cs @@ -55,12 +55,12 @@ internal sealed class SetTextExecutor(FormulaSession session) : ActionExecutor(i { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string textValue = - await context.FormatTemplateAsync( + var textValue = + await context.FormatTemplateWithSensitivityAsync( """ Test content """); - await context.QueueStateUpdateAsync(key: "TestVar", value: textValue, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestVar", value: textValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs index 84bf8ff5f56..b1939dd0d3a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/SetVariable.cs @@ -56,8 +56,8 @@ internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("3").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TestVar", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + var evaluatedValue = await context.EvaluateValueWithSensitivityAsync("3").ConfigureAwait(false); + await context.QueueStateUpdateWithSensitivityAsync(key: "TestVar", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs index e1d7af741c6..d725927777c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs @@ -299,6 +299,65 @@ internal async Task Checkpoint_Restore_ClearsQueuedExternalResponsesBeforeImport Assert.Equal(RunStatus.Idle, finalStatus); } +#if NETFRAMEWORK + /// + /// Verifies restored runs continue superstep numbering from the checkpoint's saved step. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_Lockstep, false)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep, true)] + internal async Task Checkpoint_Restore_ContinuesStepNumberFromCheckpointAsync( + ExecutionEnvironment environment, + bool rehydrateToRestore) + { + // Arrange + Workflow workflow = CreateSimpleRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + await using StreamingRun run = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello"); + + (ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run); + + // Advance the original run so live restore must rewind the tracer. + await run.SendResponseAsync(pendingRequest.CreateResponse("World")); + List firstCompletionEvents = await ReadToHaltAsync(run); + Assert.Empty(firstCompletionEvents.OfType() ?? []); + + if (rehydrateToRestore) + { + await run.DisposeAsync(); + + await using StreamingRun resumedRun = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint); + + await AssertRestoredRunContinuesFromCheckpointAsync(resumedRun); + } + else + { + await run.RestoreCheckpointAsync(checkpoint); + + await AssertRestoredRunContinuesFromCheckpointAsync(run); + } + + static async ValueTask AssertRestoredRunContinuesFromCheckpointAsync(StreamingRun restoredRun) + { + List restoredEvents = await ReadToHaltAsync(restoredRun); + ExternalRequest replayedRequest = Assert.Single(restoredEvents.OfType() + .Select(evt => evt.Request)); + + await restoredRun.SendResponseAsync(replayedRequest.CreateResponse("Again")); + List restoredCompletionEvents = await ReadToHaltAsync(restoredRun); + + Assert.Empty(restoredCompletionEvents.OfType() ?? []); + SuperStepCompletedEvent? resumedCompletion = restoredCompletionEvents.OfType().FirstOrDefault(); + Assert.NotNull(resumedCompletion); + Assert.Equal(1, resumedCompletion.StepNumber); + } + } +#endif + /// /// Verifies that fan-in edge state buffered before a checkpoint is still present after resume. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs index f203f72c58b..9795d668de1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs @@ -62,9 +62,9 @@ await environment.WithCheckpointing(checkpointManager) Assert.NotNull(result); // Depending on the timing of the response with respect to the underlying workflow - // we may end up with an extra superstep in between. - Assert.True(checkpoints.Count >= 6); - Assert.True(checkpoints.Count <= 7); + // we may end up with extra supersteps in between. + Assert.True(checkpoints.Count >= 6, $"Expected at least 6 checkpoints, got {checkpoints.Count}."); + Assert.True(checkpoints.Count <= 12, $"Expected at most 12 checkpoints, got {checkpoints.Count}."); cancellationSource.Dispose(); From 834eb7ff12c83aadee56d468e3b01b4f0fc4084c Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 23 Sep 2026 15:03:51 +0000 Subject: [PATCH 33/42] fix(dotnet): persist function results as user messages (#8655) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ed91283-cb21-49a7-98f4-b1c088d0b6b6 --- .../ObjectModel/InvokeFunctionToolExecutor.cs | 11 +++--- .../InvokeFunctionToolExecutorTest.cs | 38 +++++++++++++++---- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 20699ee4aa3..d849b168cbf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -224,7 +224,7 @@ await this.AssignAsync( // Add messages to conversation if conversationId is provided // Note: We transform messages containing FunctionResultContent or FunctionCallContent - // to assistant text messages because workflow-generated CallIds don't correspond to + // to user text messages because workflow-generated CallIds don't correspond to // actual AI-generated tool calls and would be rejected by the API. if (conversationId is not null) { @@ -336,8 +336,8 @@ await context.ReadStateAsync(HasApprovalRequiredInvocationStateKey, null, } /// - /// Transforms messages containing function-related content to assistant text messages. - /// Messages with FunctionResultContent are converted to assistant messages with the result as text. + /// Transforms messages containing function-related content to user text messages. + /// Messages with FunctionResultContent are converted to user messages with the result as text. /// Messages with only FunctionCallContent are excluded as they have no informational value. /// private static IEnumerable TransformConversationMessages(IEnumerable messages) @@ -350,7 +350,8 @@ private static IEnumerable TransformConversationMessages(IEnumerabl if (hasFunctionResult) { - // Convert function results to assistant text message + // Convert function results to user text messages so externally supplied results + // are not attributed to the assistant. List updatedContents = []; foreach (AIContent content in message.Contents) { @@ -371,7 +372,7 @@ private static IEnumerable TransformConversationMessages(IEnumerabl if (updatedContents.Count > 0) { - yield return new ChatMessage(ChatRole.Assistant, updatedContents); + yield return new ChatMessage(ChatRole.User, updatedContents); } } else if (!hasFunctionCall) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index 4a8238fb154..a3f02a61b16 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -199,27 +199,48 @@ public async Task InvokeFunctionToolCaptureResponseWithEmptyMessagesAsync() } [Fact] - public async Task InvokeFunctionToolCaptureResponseWithConversationIdAsync() + public async Task InvokeFunctionToolCaptureResponsePersistsResultAsUserTextAsync() { // Arrange this.State.InitializeSystem(); + this.State.Bind(); const string ConversationId = "TestConversationId"; + const string ResultVariable = "Result"; + const string FunctionResult = "Result for conversation"; InvokeFunctionTool model = this.CreateModel( - displayName: nameof(InvokeFunctionToolCaptureResponseWithConversationIdAsync), + displayName: nameof(InvokeFunctionToolCaptureResponsePersistsResultAsUserTextAsync), functionName: "test_function", - conversationId: ConversationId); + conversationId: ConversationId, + outputResultVariable: ResultVariable); MockAgentProvider mockAgentProvider = new(); + mockAgentProvider.TestMessages.Clear(); InvokeFunctionToolExecutor action = new(model, mockAgentProvider.Object, this.State); - FunctionResultContent functionResult = new(action.Id, "Result for conversation"); - ExternalInputResponse response = new(new ChatMessage(ChatRole.Tool, [functionResult])); + List emittedRequests = []; + Mock mockContext = CreateMockWorkflowContext(emittedRequests); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); // Act - WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + ExternalInputRequest request = Assert.Single(emittedRequests); + FunctionCallContent functionCall = GetFunctionCall(request); + ExternalInputResponse response = CreateFunctionResultResponse( + request, + expectedRequestId: functionCall.CallId, + callId: functionCall.CallId, + result: FunctionResult); + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); // Assert - VerifyModel(model, action); - Assert.NotEmpty(events); + Assert.Contains(mockContext.Invocations, i => + i.Method.Name == nameof(IWorkflowContext.QueueStateUpdateAsync) + && i.Arguments.Count >= 2 + && i.Arguments[1] is StringValue sv + && sv.Value == FunctionResult); + ChatMessage persistedMessage = Assert.Single(mockAgentProvider.TestMessages); + Assert.Equal(ChatRole.User, persistedMessage.Role); + Assert.DoesNotContain(persistedMessage.Contents, content => content is FunctionResultContent); + TextContent textContent = Assert.Single(persistedMessage.Contents.OfType()); + Assert.Contains(FunctionResult, textContent.Text, StringComparison.Ordinal); } [Fact] @@ -1368,6 +1389,7 @@ public async Task InvokeFunctionToolValidApprovalCreatesConversationMessageFromR // Assert ChatMessage persistedMessage = Assert.Single(testAgentProvider.TestMessages); + Assert.Equal(ChatRole.User, persistedMessage.Role); Assert.DoesNotContain(persistedMessage.Contents, content => content is ToolApprovalResponseContent); Assert.DoesNotContain(persistedMessage.Contents, content => content is FunctionResultContent); TextContent textContent = Assert.Single(persistedMessage.Contents.OfType()); From 30b9b8e06766b2038e366d32979357c2fc4b9def Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:01:14 +0000 Subject: [PATCH 34/42] .NET: Only consume stored approval state when the run succeeds (#8692) * Only consume stored approvals on success * Address PR comments --- ...lNotRequiredFunctionBypassingChatClient.cs | 52 +++- .../ApprovalResponseBindingChatClient.cs | 57 +++- ...equiredFunctionBypassingChatClientTests.cs | 109 +++++++ .../ApprovalResponseBindingChatClientTests.cs | 201 +++++++++++++ .../ChatClientAgent_ApprovalRetryTests.cs | 269 ++++++++++++++++++ 5 files changed, 664 insertions(+), 24 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalRetryTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs index ee7183b701f..a93a733ec73 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalNotRequiredFunctionBypassingChatClient.cs @@ -76,9 +76,16 @@ public override async Task GetResponseAsync( var autoApprovableNames = ApprovalRequirement.GetApprovalNotRequiredToolNames(this, options); - messages = InjectPendingAutoApprovals(messages, session); + var (messagesToSend, injectedAutoApprovals) = InjectPendingAutoApprovals(messages, session); - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + var response = await base.GetResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false); + + // The injected auto-approvals are cleared only now that the run has succeeded, so a failed run leaves them + // in the session and the next run injects them again instead of leaving the tool calls unanswered. + if (injectedAutoApprovals) + { + session.StateBag.TryRemoveValue(StateBagKey); + } RemoveAutoApprovedFromMessages(response.Messages, autoApprovableNames, session); @@ -103,24 +110,43 @@ public override async IAsyncEnumerable GetStreamingResponseA var autoApprovableNames = ApprovalRequirement.GetApprovalNotRequiredToolNames(this, options); - messages = InjectPendingAutoApprovals(messages, session); + var (messagesToSend, injectedAutoApprovals) = InjectPendingAutoApprovals(messages, session); List? autoApproved = null; + // Set only once the stream has run to completion, so that any abnormal end - an exception from the inner + // client, a cancellation, or a consumer that stops enumerating early - leaves the stored state exactly as it + // was. A caught exception is not enough on its own: breaking out of the enumeration disposes this iterator + // without throwing, and the run then persists nothing either. + bool completedNormally = false; + try { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + await foreach (var update in base.GetStreamingResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false)) { if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved)) { yield return update; } } + + completedNormally = true; } finally { - if (autoApproved is { Count: > 0 }) + // Both writes are gated, mirroring the non-streaming path: the requests collected here were filtered out + // of the stream and so never reached the caller, and storing them on an abnormal end would overwrite the + // batch that was injected this run and still needs re-injecting. + if (completedNormally) { - session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions); + if (injectedAutoApprovals) + { + session.StateBag.TryRemoveValue(StateBagKey); + } + + if (autoApproved is { Count: > 0 }) + { + session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions); + } } } } @@ -159,7 +185,13 @@ private bool TryGetSession([NotNullWhen(true)] out AgentSession? session) /// All stored requests are unconditionally injected as approved responses regardless of whether the /// tool set has changed, because the LLM requires a complete set of tool call responses for a prior turn. /// - private static IEnumerable InjectPendingAutoApprovals( + /// + /// The messages to send to the inner client, and whether any auto-approvals were injected into them. The + /// stored auto-approvals are cleared by the caller only once the run has completed successfully, so that a + /// failed run leaves them in the session and the next run injects them again rather than leaving the + /// auto-approved calls unanswered. + /// + private static (IEnumerable Messages, bool Injected) InjectPendingAutoApprovals( IEnumerable messages, AgentSession session) { @@ -169,11 +201,9 @@ private static IEnumerable InjectPendingAutoApprovals( AgentJsonUtilities.DefaultOptions) || pendingRequests is not { Count: > 0 }) { - return messages; + return (messages, false); } - session.StateBag.TryRemoveValue(StateBagKey); - List approvalResponses = []; foreach (var request in pendingRequests) { @@ -181,7 +211,7 @@ private static IEnumerable InjectPendingAutoApprovals( } var userMessage = new ChatMessage(ChatRole.User, approvalResponses); - return messages.Concat([userMessage]); + return (messages.Concat([userMessage]), true); } /// diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs index c4c52a7cbe6..722130be726 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ApprovalResponseBindingChatClient.cs @@ -85,9 +85,17 @@ public override async Task GetResponseAsync( return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); } - messages = this.ValidateInboundApprovalResponses(messages, session); + var (messagesToSend, hasPendingRequests) = this.ValidateInboundApprovalResponses(messages, session); - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + var response = await base.GetResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false); + + // The records are consumed only now that the run has succeeded, so a failed run leaves them intact and the + // caller can supply the same approval response again. This runs before the requests surfaced by this run + // are recorded, so it never discards those. + if (hasPendingRequests) + { + session.StateBag.TryRemoveValue(StateBagKey); + } this.RecordPendingApprovalRequests(response.Messages, session); @@ -110,13 +118,19 @@ public override async IAsyncEnumerable GetStreamingResponseA yield break; } - messages = this.ValidateInboundApprovalResponses(messages, session); + var (messagesToSend, hasPendingRequests) = this.ValidateInboundApprovalResponses(messages, session); List? emitted = null; + // Set only once the stream has run to completion, so that any abnormal end - an exception from the inner + // client, a cancellation, or a consumer that stops enumerating early - leaves the records intact and lets the + // caller supply the same approval response again. A caught exception is not enough on its own: breaking out of + // the enumeration disposes this iterator without throwing, and the run then persists nothing either. + bool completedNormally = false; + try { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + await foreach (var update in base.GetStreamingResponseAsync(messagesToSend, options, cancellationToken).ConfigureAwait(false)) { foreach (var content in update.Contents) { @@ -128,9 +142,19 @@ public override async IAsyncEnumerable GetStreamingResponseA yield return update; } + + completedNormally = true; } finally { + if (completedNormally && hasPendingRequests) + { + session.StateBag.TryRemoveValue(StateBagKey); + } + + // Recorded regardless of how the stream ended, because each request was already handed to the caller + // before the stream stopped and the caller may act on it. Consumers commonly stop enumerating as soon as + // they see an approval request, and the record has to be in place for the answer to bind. if (emitted is { Count: > 0 }) { this.MergePendingApprovalRequests(emitted, session); @@ -173,7 +197,15 @@ private bool TryGetSession([NotNullWhen(true)] out AgentSession? session) /// function invocation middleware. That is deliberate: a payload whose approval was rejected surfaces as an /// error instead of silently continuing as though the call had never been requested. /// - private IEnumerable ValidateInboundApprovalResponses(IEnumerable messages, AgentSession session) + /// The inbound messages. + /// The session holding the recorded pending approval requests. + /// + /// The messages to send to the inner client, and whether the session held any recorded pending requests, which + /// the caller consumes once the inner call has succeeded. + /// + private (IEnumerable Messages, bool HasPendingRequests) ValidateInboundApprovalResponses( + IEnumerable messages, + AgentSession session) { var messageList = messages as IList ?? new List(messages); @@ -183,12 +215,11 @@ private IEnumerable ValidateInboundApprovalResponses(IEnumerable 0) - { - session.StateBag.TryRemoveValue(StateBagKey); - } + // Pending state only needs to bridge a single turn. Tool-call results must be supplied as a complete set, + // so an approval batch is always answered in one turn and nothing is left to carry over. It is consumed by + // the caller once the inner call has succeeded rather than here, because a run that fails persists neither + // the approval response nor anything else, and the caller must be able to supply the same response again. + var hasPendingRequests = knownRequests.Count > 0; // Tool calls that already carry a result in the inbound messages. The approval gate guards execution, and // a call whose result is already present will not be executed again, so its approval is settled history @@ -220,7 +251,7 @@ private IEnumerable ValidateInboundApprovalResponses(IEnumerable ValidateInboundApprovalResponses(IEnumerable diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalNotRequiredFunctionBypassingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalNotRequiredFunctionBypassingChatClientTests.cs index 1a49d48f2b2..581d8e01709 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalNotRequiredFunctionBypassingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalNotRequiredFunctionBypassingChatClientTests.cs @@ -241,6 +241,93 @@ public async Task GetResponseAsync_NextRequest_ClearsStoredAfterInjectionAsync() ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions)); } + [Fact] + public async Task GetResponseAsync_InnerCallThrows_RetainsStoredAutoApprovalsAsync() + { + // Arrange — the run that carries the injected auto-approvals fails. Nothing is persisted by a failed run, so + // the stored auto-approvals must survive for the next run to inject them again. + var fccNormal = new FunctionCallContent("call1", "normalTool"); + var storedApproval = new ToolApprovalRequestContent("req1", fccNormal); + + var session = new ChatClientAgentSession(); + session.StateBag.SetValue( + ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, + new List { storedApproval }, + AgentJsonUtilities.DefaultOptions); + + var innerClient = CreateMockChatClient((_, _, _) => throw new InvalidOperationException("Service failure.")); + + var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient); + var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] }; + + // Act + await Assert.ThrowsAsync(() => RunWithAgentContextAsync(decorator, session, options)); + + // Assert + Assert.True(session.StateBag.TryGetValue>( + ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions)); + Assert.Equal("req1", Assert.Single(stored!).RequestId); + } + + [Fact] + public async Task GetStreamingResponseAsync_InnerCallThrows_RetainsStoredAutoApprovalsAsync() + { + // Arrange + var fccNormal = new FunctionCallContent("call1", "normalTool"); + var storedApproval = new ToolApprovalRequestContent("req1", fccNormal); + + var session = new ChatClientAgentSession(); + session.StateBag.SetValue( + ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, + new List { storedApproval }, + AgentJsonUtilities.DefaultOptions); + + var innerClient = CreateMockStreamingChatClient((_, _, _) => + ThrowingUpdatesAsync(new InvalidOperationException("Service failure."))); + + var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient); + var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] }; + + // Act + await Assert.ThrowsAsync( + () => RunStreamingWithAgentContextAsync(decorator, session, [], options)); + + // Assert + Assert.True(session.StateBag.TryGetValue>( + ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions)); + Assert.Equal("req1", Assert.Single(stored!).RequestId); + } + + [Fact] + public async Task GetStreamingResponseAsync_ThrowsAfterCollectingNewAutoApprovals_DoesNotOverwriteStoredAutoApprovalsAsync() + { + // Arrange — an earlier auto-approval is injected into this run, and the stream then surfaces a further + // auto-approvable call before failing. The new request was filtered out of the stream and the failed run + // persists nothing, so storing it would discard the injected one and leave its call unanswered forever. + var session = new ChatClientAgentSession(); + session.StateBag.SetValue( + ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, + new List { new("req1", new FunctionCallContent("call1", "normalTool")) }, + AgentJsonUtilities.DefaultOptions); + + var innerClient = CreateMockStreamingChatClient((_, _, _) => UpdatesThenThrowAsync( + new InvalidOperationException("Service failure."), + new ChatResponseUpdate(ChatRole.Assistant, [ + new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "normalTool"))]))); + + var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient); + var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] }; + + // Act + await Assert.ThrowsAsync( + () => RunStreamingWithAgentContextAsync(decorator, session, [], options)); + + // Assert — the injected batch is still the stored one, so the next run can inject it again. + Assert.True(session.StateBag.TryGetValue>( + ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions)); + Assert.Equal("req1", Assert.Single(stored!).RequestId); + } + [Fact] public async Task GetResponseAsync_UnknownTool_TreatedAsApprovalRequiredAsync() { @@ -723,5 +810,27 @@ private static async IAsyncEnumerable ToAsyncEnumerableAsync await Task.CompletedTask; } +#pragma warning disable CS1998 // Async method lacks 'await' operators + private static async IAsyncEnumerable UpdatesThenThrowAsync( + Exception exception, + params ChatResponseUpdate[] updates) + { + foreach (var update in updates) + { + yield return update; + } + + throw exception; + } + + private static async IAsyncEnumerable ThrowingUpdatesAsync(Exception exception) + { + throw exception; +#pragma warning disable CS0162 // Unreachable code detected + yield break; +#pragma warning restore CS0162 + } +#pragma warning restore CS1998 + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs index 26e28d019c7..05afede2b00 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ApprovalResponseBindingChatClientTests.cs @@ -383,6 +383,149 @@ public async Task GetResponseAsync_NoSession_PassesThroughUnvalidatedAsync() Assert.Contains(capture.Messages!.SelectMany(m => m.Contents), c => c is ToolApprovalResponseContent); } + [Fact] + public async Task GetResponseAsync_PartiallyAnsweredBatch_ConsumesWholeBatchAsync() + { + // Arrange — two requests were surfaced, and the caller answers only one of them. Tool-call results must be + // supplied as a complete set, so an approval batch is answered in a single turn; a record must not outlive + // that turn, because it stays usable to authorize a call for as long as it is kept. + var session = new ChatClientAgentSession(); + var firstCall = new FunctionCallContent("call1", "toolA"); + var secondCall = new FunctionCallContent("call2", "toolB"); + await RecordRequestsAsync(session, [ + new ToolApprovalRequestContent(RequestId, firstCall), + new ToolApprovalRequestContent("req2", secondCall)]); + + var response = new ToolApprovalResponseContent(RequestId, approved: true, firstCall); + var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(new Capture())); + + // Act + await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [response])]); + + // Assert — neither record survives the turn. + Assert.False(HasPendingRequest(session, RequestId)); + Assert.False(HasPendingRequest(session, "req2")); + } + + [Fact] + public async Task GetResponseAsync_InnerCallThrows_RetainsPendingEntryForRetryAsync() + { + // Arrange — the run that carries the approval response back to the agent fails, as a transient transport + // error or a cancellation does. Chat history is only written when a run completes, so the surfaced request + // stays in the history and the caller must be able to send the same approval response again. + var session = new ChatClientAgentSession(); + var recordedCall = new FunctionCallContent("call1", "toolA"); + await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, recordedCall)); + + var response = new ToolApprovalResponseContent(RequestId, approved: true, recordedCall); + var inner = CreateMockChatClient((_, _, _) => throw new InvalidOperationException("Service failure.")); + var decorator = new ApprovalResponseBindingChatClient(inner); + + // Act + await Assert.ThrowsAsync( + () => RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [response])])); + + // Assert — the pending entry survives, so the same approval can be supplied again. + Assert.True(HasPendingRequest(session, RequestId)); + } + + [Fact] + public async Task GetStreamingResponseAsync_InnerCallThrows_RetainsPendingEntryForRetryAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var recordedCall = new FunctionCallContent("call1", "toolA"); + await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, recordedCall)); + + var response = new ToolApprovalResponseContent(RequestId, approved: true, recordedCall); + var inner = CreateMockStreamingChatClient((_, _, _) => + ThrowingUpdatesAsync(new InvalidOperationException("Service failure."))); + var decorator = new ApprovalResponseBindingChatClient(inner); + + // Act + await Assert.ThrowsAsync( + () => RunStreamingAsync(decorator, session, [new ChatMessage(ChatRole.User, [response])])); + + // Assert + Assert.True(HasPendingRequest(session, RequestId)); + } + + [Fact] + public async Task GetStreamingResponseAsync_ConsumerStopsEarly_RetainsPendingEntryAndRecordsEmittedRequestAsync() + { + // Arrange — a consumer commonly stops enumerating the moment it sees an approval request, because it now + // needs the user's decision. Disposing the stream early skips the end-of-run history write just like a + // failure does, so the stored state must survive untouched, while the request that was already handed to the + // caller must be recorded so the answer to it can bind. + var session = new ChatClientAgentSession(); + var recordedCall = new FunctionCallContent("call1", "toolA"); + await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, recordedCall)); + + var response = new ToolApprovalResponseContent(RequestId, approved: true, recordedCall); + var newRequest = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "toolB")); + var inner = CreateMockStreamingChatClient((_, _, _) => UpdatesAsync( + new ChatResponseUpdate(ChatRole.Assistant, [newRequest]), + new ChatResponseUpdate(ChatRole.Assistant, "trailing"))); + var decorator = new ApprovalResponseBindingChatClient(inner); + + var agent = new TestAIAgent + { + RunAsyncFunc = async (_, _, _, ct) => + { + await foreach (var update in decorator.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, [response])], null, ct)) + { + if (update.Contents.OfType().Any()) + { + break; + } + } + + return new AgentResponse(); + } + }; + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "drive")], session); + + // Assert + Assert.True(HasPendingRequest(session, RequestId)); + Assert.True(HasPendingRequest(session, "req2")); + } + + [Fact] + public async Task GetResponseAsync_RetriedAfterFailedRun_IsHonoredOnceAsync() + { + // Arrange — the first attempt fails, then the caller sends the very same approval response again. + var session = new ChatClientAgentSession(); + var recordedCall = new FunctionCallContent("call1", "toolA"); + await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, recordedCall)); + + var failing = CreateMockChatClient((_, _, _) => throw new InvalidOperationException("Service failure.")); + await Assert.ThrowsAsync(() => RunAsync( + new ApprovalResponseBindingChatClient(failing), + session, + [new ChatMessage(ChatRole.User, [new ToolApprovalResponseContent(RequestId, approved: true, recordedCall)])])); + + var capture = new Capture(); + var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(capture)); + + // Act + await RunAsync(decorator, session, + [new ChatMessage(ChatRole.User, [new ToolApprovalResponseContent(RequestId, approved: true, recordedCall)])]); + + // Assert — the retry is bound and forwarded, and the entry is retired once the run has succeeded, so a + // further replay of the same approval is no longer honored. + var forwarded = capture.Messages!.SelectMany(m => m.Contents).OfType().Single(); + Assert.True(forwarded.Approved); + Assert.False(HasPendingRequest(session, RequestId)); + } + + private static bool HasPendingRequest(AgentSession session, string requestId) + => session.StateBag.TryGetValue>( + ApprovalResponseBindingChatClient.StateBagKey, out var pending) + && pending?.Exists(request => request.RequestId == requestId) is true; + private static async Task RecordRequestAsync(ChatClientAgentSession session, ToolApprovalRequestContent request) { var inner = CreateMockChatClient((_, _, _) => @@ -391,6 +534,14 @@ private static async Task RecordRequestAsync(ChatClientAgentSession session, Too await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, "Hi")]); } + private static async Task RecordRequestsAsync(ChatClientAgentSession session, IList requests) + { + var inner = CreateMockChatClient((_, _, _) => + Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [.. requests])]))); + var decorator = new ApprovalResponseBindingChatClient(inner); + await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, "Hi")]); + } + private static async Task RunAsync( IChatClient decorator, AgentSession session, @@ -706,4 +857,54 @@ private static IChatClient CreateMockChatClient( .Returns((IEnumerable m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct)); return mock.Object; } + + private static IChatClient CreateMockStreamingChatClient( + Func, ChatOptions?, CancellationToken, IAsyncEnumerable> onGetStreamingResponse) + { + var mock = new Mock(); + mock.Setup(c => c.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((IEnumerable m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct)); + return mock.Object; + } + + private static async Task RunStreamingAsync( + ApprovalResponseBindingChatClient decorator, + AgentSession session, + IList input, + ChatOptions? options = null) + { + var agent = new TestAIAgent + { + RunAsyncFunc = async (_, _, _, ct) => + { + List updates = []; + await foreach (var update in decorator.GetStreamingResponseAsync(input, options, ct)) + { + updates.Add(update); + } + + return new AgentResponse(updates.ToChatResponse()); + } + }; + + await agent.RunAsync([new ChatMessage(ChatRole.User, "drive")], session); + } + +#pragma warning disable CS1998 // Async method lacks 'await' operators + private static async IAsyncEnumerable UpdatesAsync(params ChatResponseUpdate[] updates) + { + foreach (var update in updates) + { + yield return update; + } + } + + private static async IAsyncEnumerable ThrowingUpdatesAsync(Exception exception) + { + throw exception; +#pragma warning disable CS0162 // Unreachable code detected + yield break; +#pragma warning restore CS0162 + } +#pragma warning restore CS1998 } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalRetryTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalRetryTests.cs new file mode 100644 index 00000000000..d3eb637bc44 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalRetryTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests covering the recovery path for the failure described in +/// https://github.com/microsoft/agent-framework/issues/8575: when the run that carries the +/// back to the agent fails, the chat history keeps the +/// without a response, so the same approval response has to remain +/// deliverable. The record the framework keeps of a surfaced request is therefore retired only once the run that +/// consumed it has succeeded. +/// +public class ChatClientAgent_ApprovalRetryTests +{ + /// + /// Verifies that the approval response can be sent again after the run carrying it failed, and that the record + /// is retired once that run has succeeded, so an approval is still honored only once. + /// + [Fact] + public async Task RunAsync_WhenApprovalResumeFails_SameApprovalResponseCanBeSentAgainAsync() + { + // Arrange + var callCount = 0; + var chatClient = CreateChatClient((_, _, _) => + { + callCount++; + return callCount switch + { + 1 => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather")])])), + 2 => throw new HttpRequestException("Simulated transient failure while resuming the approval."), + _ => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "The weather is sunny.")])), + }; + }); + + var agent = CreateAgent(chatClient); + var session = await agent.CreateSessionAsync(); + + // Act — Turn 1 surfaces the approval request and records it. + var firstResponse = await agent.RunAsync("What's the weather?", session); + var approvalRequests = GetApprovalRequests(firstResponse); + + // Act — Turn 2 carries the approval response, and fails at the transport. + await Assert.ThrowsAsync(() => agent.RunAsync(CreateApprovalResponses(approvalRequests), session)); + + var recordedAfterFailedTurn = HasPendingApprovalRequests(session); + + // Act — Turn 3 sends the very same approval response again. + var retriedResponse = await agent.RunAsync(CreateApprovalResponses(approvalRequests), session); + + // Assert + Assert.Single(approvalRequests); + Assert.True(recordedAfterFailedTurn, "A failed run must not discard the record of the surfaced approval request."); + Assert.Equal("The weather is sunny.", retriedResponse.Text); + Assert.False(HasPendingApprovalRequests(session), "The record is retired once the run that consumed it has succeeded."); + } + + /// + /// Verifies that the approval response also remains deliverable when the host reloads the session from a store + /// after the failed run, which is the shape the issue was reported in. + /// + /// + /// + /// A store writes the state of a session when a run completes, so the state restored here is the one written at + /// the end of the successful first turn: the failed resume persisted neither the chat history nor the session + /// state. The approval response is built from the request as the store returns it, which is what a host that + /// survives a process restart works from. + /// + /// + /// The tool runs a second time, because the run that already invoked it persisted nothing: neither the chat + /// history nor any other record of that invocation survived, so replaying the approval is a retry of a call the + /// conversation has no result for. Tools whose effects must not be duplicated need to be idempotent. + /// + /// + [Fact] + public async Task RunAsync_WhenApprovalResumeFails_RestoredSessionCanSendApprovalResponseAgainAsync() + { + // Arrange + var toolInvocations = 0; + var callCount = 0; + var chatClient = CreateChatClient((_, _, _) => + { + callCount++; + return callCount switch + { + 1 => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather")])])), + 2 => throw new HttpRequestException("Simulated transient failure while resuming the approval."), + _ => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "The weather is sunny.")])), + }; + }); + + var agent = CreateAgent(chatClient, () => { toolInvocations++; return "Sunny, 22\u00B0C"; }); + var session = await agent.CreateSessionAsync(); + + var firstResponse = await agent.RunAsync("What's the weather?", session); + var persistedState = await agent.SerializeSessionAsync(session); + + await Assert.ThrowsAsync( + () => agent.RunAsync(CreateApprovalResponses(GetApprovalRequests(firstResponse)), session)); + + var toolInvocationsAfterFailedTurn = toolInvocations; + + // Act — the host reloads the session from the store and answers the request it holds. + var restoredSession = await agent.DeserializeSessionAsync(persistedState); + var restoredRequests = ChatClientAgentTestHelper.GetPersistedHistory(agent, restoredSession) + .SelectMany(m => m.Contents).OfType().ToList(); + + var retriedResponse = await agent.RunAsync(CreateApprovalResponses(restoredRequests), restoredSession); + + // Assert + Assert.Equal("The weather is sunny.", retriedResponse.Text); + Assert.Equal(1, toolInvocationsAfterFailedTurn); + Assert.Equal(2, toolInvocations); + + // The approval request and the result of the call it authorized are both in the history, so nothing is left + // unanswered. + var history = ChatClientAgentTestHelper.GetPersistedHistory(agent, restoredSession); + Assert.Contains(history.SelectMany(m => m.Contents), c => c is ToolApprovalRequestContent); + Assert.Contains(history.SelectMany(m => m.Contents), c => c is FunctionResultContent); + } + + /// + /// Verifies that the streaming path keeps the record of a surfaced approval request when the run fails, so the + /// approval response remains deliverable there too. + /// + [Fact] + public async Task RunStreamingAsync_WhenApprovalResumeFails_SameApprovalResponseCanBeSentAgainAsync() + { + // Arrange + var callCount = 0; + var chatClient = CreateStreamingChatClient(() => + { + callCount++; + return callCount switch + { + 1 => [new ChatResponseUpdate(ChatRole.Assistant, new AIContent[] { new FunctionCallContent("call1", "GetWeather") })], + 2 => throw new HttpRequestException("Simulated transient failure while resuming the approval."), + _ => [new ChatResponseUpdate(ChatRole.Assistant, "The weather is sunny.")], + }; + }); + + var agent = CreateAgent(chatClient); + var session = await agent.CreateSessionAsync(); + + var firstResponse = await agent.RunStreamingAsync("What's the weather?", session).ToAgentResponseAsync(); + var approvalRequests = GetApprovalRequests(firstResponse); + + await Assert.ThrowsAsync(async () => + await agent.RunStreamingAsync(CreateApprovalResponses(approvalRequests), session).ToAgentResponseAsync()); + + var recordedAfterFailedTurn = HasPendingApprovalRequests(session); + + // Act + var retriedResponse = await agent.RunStreamingAsync(CreateApprovalResponses(approvalRequests), session).ToAgentResponseAsync(); + + // Assert + Assert.True(recordedAfterFailedTurn, "A failed run must not discard the record of the surfaced approval request."); + Assert.Equal("The weather is sunny.", retriedResponse.Text); + Assert.False(HasPendingApprovalRequests(session), "The record is retired once the run that consumed it has succeeded."); + } + + /// + /// Verifies that a rejection can also be sent again after a failed run, and that the rejected call is settled + /// without the tool being executed. + /// + [Fact] + public async Task RunAsync_WhenRejectionResumeFails_RejectionCanBeSentAgainAsync() + { + // Arrange + var toolInvocations = 0; + var callCount = 0; + var chatClient = CreateChatClient((_, _, _) => + { + callCount++; + return callCount switch + { + 1 => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather")])])), + 2 => throw new HttpRequestException("Simulated transient failure while resuming the approval."), + _ => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Understood, I will not check the weather.")])), + }; + }); + + var agent = CreateAgent(chatClient, () => { toolInvocations++; return "Sunny, 22°C"; }); + var session = await agent.CreateSessionAsync(); + + var firstResponse = await agent.RunAsync("What's the weather?", session); + var approvalRequests = GetApprovalRequests(firstResponse); + + await Assert.ThrowsAsync( + () => agent.RunAsync(CreateApprovalResponses(approvalRequests, approved: false), session)); + + // Act + var retriedResponse = await agent.RunAsync(CreateApprovalResponses(approvalRequests, approved: false), session); + + // Assert + Assert.Equal("Understood, I will not check the weather.", retriedResponse.Text); + Assert.Equal(0, toolInvocations); + } + + private static bool HasPendingApprovalRequests(AgentSession session) + => session.StateBag.TryGetValue>( + ApprovalResponseBindingChatClient.StateBagKey, + out var pending, + AgentJsonUtilities.DefaultOptions) + && pending is { Count: > 0 }; + + private static ChatClientAgent CreateAgent(IChatClient chatClient, Func? getWeather = null) + => new( + chatClient, + options: new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(getWeather ?? (() => "Sunny, 22°C"), "GetWeather"))] + }, + ChatHistoryProvider = new InMemoryChatHistoryProvider() + }, + services: new ServiceCollection().BuildServiceProvider()); + + private static List CreateApprovalResponses(List approvalRequests, bool approved = true) + => approvalRequests.ConvertAll(request => + new ChatMessage(ChatRole.User, [request.CreateResponse(approved)])); + + private static List GetApprovalRequests(AgentResponse response) + => response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + + private static IChatClient CreateChatClient( + Func, ChatOptions?, CancellationToken, Task> onGetResponse) + { + var mock = new Mock(); + mock.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(onGetResponse); + return mock.Object; + } + + private static IChatClient CreateStreamingChatClient(Func> onGetStreamingResponse) + { + var mock = new Mock(); + mock.Setup(c => c.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((IEnumerable _, ChatOptions? _, CancellationToken cancellationToken) + => ToAsyncEnumerableAsync(onGetStreamingResponse(), cancellationToken)); + return mock.Object; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + List updates, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var update in updates) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return update; + } + } +} From 622737258c73f730adca5130b7b82fbc03ff8838 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:17:44 +0000 Subject: [PATCH 35/42] Set better expecations for contributors (#8706) * Set better expecations for contributors * Address PR comments --- CONTRIBUTING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 554640c23c3..f64cdbf4607 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,17 @@ report should contain the following information: Project maintainers will merge accepted code changes from contributors. +We welcome contributions, but maintainers must prioritize their limited review time +across issues and pull requests, so we may not be able to get to reviewing every +contribution immediately. + +AI tooling has also made it easier for more people to +submit contributions, which has increased the overall volume we receive; as a +result, contributors should expect longer wait times for reviews. + +Note that your pull request may receive automated review comments from a bot; addressing or +at least replying to those comments will help fast track the eventual human review. + ### DOs and DON'Ts DO's: From 7fb41d673c97c51779de45e8ccd4655afbe935f7 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 23 Sep 2026 18:22:13 +0000 Subject: [PATCH 36/42] Python: add option to disable return-to-previous handoff routing (#8639) * Python: make handoff reply routing configurable * Test handoff start-agent response context --- python/packages/orchestrations/README.md | 13 ++++ .../_handoff.py | 44 +++++++++++- .../orchestrations/tests/test_handoff.py | 67 +++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/python/packages/orchestrations/README.md b/python/packages/orchestrations/README.md index bbdb63a118a..8c2b3c4bb26 100644 --- a/python/packages/orchestrations/README.md +++ b/python/packages/orchestrations/README.md @@ -51,6 +51,19 @@ workflow = ( ) ``` +By default, each user response returns to the agent that requested it. To have the start agent re-evaluate every user +turn, disable return-to-previous routing: + +```python +workflow = ( + HandoffBuilder() + .participants([triage, billing, support]) + .with_start_agent(triage) + .enable_return_to_previous(False) + .build() +) +``` + ### GroupChatBuilder Orchestrator-directed multi-agent conversations: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index 490e85708a3..aadcb8ad6df 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -241,6 +241,7 @@ def __init__( *, agent_session: AgentSession | None = None, is_start_agent: bool = False, + user_response_target_id: str | None = None, termination_condition: TerminationCondition | None = None, autonomous_mode: bool = False, autonomous_mode_prompt: str | None = None, @@ -254,6 +255,8 @@ def __init__( agent_session: Optional AgentSession that manages the agent's execution context is_start_agent: Whether this agent is the starting agent in the handoff workflow. There can only be one starting agent in a handoff workflow. + user_response_target_id: Optional executor ID that should handle user responses. + Defaults to this executor. termination_condition: Optional callable that determines when to terminate the workflow autonomous_mode: Whether the agent should operate involve external systems after a response that does not trigger a handoff or before the turn @@ -271,6 +274,7 @@ def __init__( self._handoff_targets = {handoff.target_id for handoff in handoffs} self._termination_condition = termination_condition self._is_start_agent = is_start_agent + self._user_response_target_id = user_response_target_id # Autonomous mode members self._autonomous_mode = autonomous_mode @@ -510,8 +514,15 @@ async def handle_response( # Broadcast the user response to all other agents await self._broadcast_messages(response, ctx) - # Append the user response messages to the cache + # Keep this executor synchronized even when another agent handles the response. self._cache.extend(response) + if self._user_response_target_id is not None and self._user_response_target_id != self.id: + await ctx.send_message( + AgentExecutorRequest(messages=[], should_respond=True), + target_id=self._user_response_target_id, + ) + return + await self._run_agent_and_emit(ctx) async def _broadcast_messages( @@ -623,6 +634,8 @@ class HandoffBuilder: Agents can hand off to other agents using `.add_handoff()`. This provides a decentralized approach to multi-agent collaboration. Handoffs can be configured using `.add_handoff`. If none are specified, all agents can hand off to all others by default (making a mesh topology). + By default, user responses return to the agent that requested them. Use + `.enable_return_to_previous(False)` to route user responses through the start agent instead. Participants must be ``Agent`` instances. ``SupportsAgentRun`` protocol implementors that are not ``Agent`` subclasses are not supported because handoff workflows require cloning, @@ -690,6 +703,7 @@ def __init__( # Handoff related members self._handoff_config: dict[str, set[HandoffConfiguration]] = {} + self._return_to_previous: bool = True # Checkpoint related members self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage @@ -864,6 +878,33 @@ def with_start_agent(self, agent: Agent) -> "HandoffBuilder": return self + def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder": + """Configure whether user responses return to the agent that requested them. + + Return-to-previous routing is enabled by default. Disable it to route each user response + through the configured start agent so that agent can re-evaluate the conversation before + handing off again. + + Args: + enabled: Whether user responses should return to the agent that requested them. + + Returns: + Self for method chaining. + + Example: + + .. code-block:: python + + workflow = ( + HandoffBuilder(participants=[triage, billing, support]) + .with_start_agent(triage) + .enable_return_to_previous(False) + .build() + ) + """ + self._return_to_previous = enabled + return self + def with_autonomous_mode( self, *, @@ -1155,6 +1196,7 @@ def _resolve_executors( agent=agent, handoffs=handoffs.get(resolved_id, []), is_start_agent=(id == self._start_id), + user_response_target_id=None if self._return_to_previous else self._start_id, termination_condition=self._termination_condition, autonomous_mode=autonomous_mode, autonomous_mode_prompt=self._autonomous_mode_prompts.get(id, None), diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 099219a18c0..781e64b73b9 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -84,6 +84,7 @@ def __init__( self._name = name self._handoff_to = handoff_to self._call_index = 0 + self.received_messages: list[list[Message]] = [] def _inner_get_response( self, @@ -93,6 +94,7 @@ def _inner_get_response( options: Mapping[str, Any], **kwargs: Any, ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + self.received_messages.append(list(messages)) if stream: return self._build_streaming_response(options=dict(options)) @@ -303,6 +305,71 @@ def _request_text(event: WorkflowEvent[Any]) -> str: return messages[-1].text or "" +@pytest.mark.parametrize( + ("return_to_previous", "expected_start_agent_calls"), + [ + param(None, 1, id="default"), + param(True, 1, id="enabled"), + param(False, 2, id="disabled"), + ], +) +async def test_handoff_user_response_routing( + return_to_previous: bool | None, + expected_start_agent_calls: int, +) -> None: + """User responses return to the prior agent unless configured to revisit the start agent.""" + triage_client = MockChatClient(name="triage", handoff_to="specialist") + specialist_client = MockChatClient(name="specialist") + triage = Agent( + id="triage", + name="triage", + client=triage_client, + require_per_service_call_history_persistence=True, + ) + specialist = Agent( + id="specialist", + name="specialist", + client=specialist_client, + require_per_service_call_history_persistence=True, + ) + builder = HandoffBuilder( + participants=_as_handoff_agents(triage, specialist), + termination_condition=lambda _: False, + ).with_start_agent(_as_handoff_agent(triage)) + if return_to_previous is True: + builder.enable_return_to_previous() + elif return_to_previous is False: + builder.enable_return_to_previous(False) + workflow = builder.build() + + first_events = await _drain(workflow.run("Need specialist help", stream=True)) + first_request = _latest_request_info_event(first_events) + assert first_request.source_executor_id == specialist.name + assert len(triage_client.received_messages) == 1 + assert len(specialist_client.received_messages) == 1 + + second_events = await _drain( + workflow.run( + stream=True, + responses={first_request.request_id: [Message(role="user", contents=["Additional details"])]}, + ) + ) + second_request = _latest_request_info_event(second_events) + + assert second_request.source_executor_id == specialist.name + assert len(triage_client.received_messages) == expected_start_agent_calls + assert len(specialist_client.received_messages) == 2 + if return_to_previous is False: + assert any( + message.role == "user" and message.text == "Additional details" + for message in triage_client.received_messages[-1] + ) + assert any( + message.role == "user" and message.text == "Additional details" + for message in specialist_client.received_messages[-1] + ) + + async def test_resume_keeps_prior_user_context_for_same_agent() -> None: """Ensure same-agent request_info resumes retain prior turn context.""" refund_agent = Agent( From 4f4e72284c9c6699548eb4bfd20ea40cc3de2bae Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 24 Sep 2026 08:54:35 +0000 Subject: [PATCH 37/42] Python: Scope workflow invocation kwargs by executor graph (#8596) * Python: Scope workflow invocation kwargs by executor graph * Python: Address workflow kwargs review feedback * Python: Preserve legacy nested kwargs validation * Python: Preserve transparent agent-name routing * Python: Preserve plain workflow kwargs semantics --- .../core/agent_framework/_workflows/_const.py | 4 + .../agent_framework/_workflows/_workflow.py | 126 ++++++-- .../_workflows/_workflow_executor.py | 86 +++++- .../tests/workflow/test_workflow_kwargs.py | 286 ++++++++++++++++++ 4 files changed, 473 insertions(+), 29 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_const.py b/python/packages/core/agent_framework/_workflows/_const.py index d939bd448cb..db99b45426a 100644 --- a/python/packages/core/agent_framework/_workflows/_const.py +++ b/python/packages/core/agent_framework/_workflows/_const.py @@ -22,6 +22,10 @@ # older first-party packages and checkpoints. RESOLVED_WORKFLOW_RUN_KWARGS_KEY = "_resolved_workflow_run_kwargs" +# Key used to record caller keys that targeted executors in the current workflow. +# Nested workflows use this to avoid reinterpreting parent-scoped entries. +ROUTED_WORKFLOW_RUN_KWARGS_KEY = "_routed_workflow_run_kwargs" + # State keys used to preserve caller-provided kwargs for nested workflow routing. RAW_FUNCTION_INVOCATION_KWARGS_KEY = "_raw_function_invocation_kwargs" RAW_CLIENT_KWARGS_KEY = "_raw_client_kwargs" diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index be6d8ffc707..1fb3f54d947 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -33,6 +33,7 @@ RAW_CLIENT_KWARGS_KEY, RAW_FUNCTION_INVOCATION_KWARGS_KEY, RESOLVED_WORKFLOW_RUN_KWARGS_KEY, + ROUTED_WORKFLOW_RUN_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY, ) from ._edge import ( @@ -599,29 +600,38 @@ async def _run_workflow_with_tracing( if function_invocation_kwargs is not None or client_kwargs is not None: combined_kwargs: dict[str, Any] = {} resolved_combined_kwargs: dict[str, Any] = {} + routed_combined_kwargs: dict[str, list[str]] = {} if function_invocation_kwargs is not None: - resolved = self._resolve_invocation_kwargs( + resolved, routed_keys = self._resolve_invocation_kwargs( function_invocation_kwargs, "function_invocation_kwargs" ) resolved_combined_kwargs["function_invocation_kwargs"] = resolved + routed_combined_kwargs["function_invocation_kwargs"] = sorted(routed_keys) combined_kwargs["function_invocation_kwargs"] = self._to_legacy_invocation_kwargs(resolved) - if isinstance(function_invocation_kwargs, WorkflowInvocationKwargs) or any( - isinstance(value, Mapping) for value in function_invocation_kwargs.values() + if ( + isinstance(function_invocation_kwargs, WorkflowInvocationKwargs) + or routed_keys + or any(isinstance(value, Mapping) for value in function_invocation_kwargs.values()) ): combined_kwargs[RAW_FUNCTION_INVOCATION_KWARGS_KEY] = function_invocation_kwargs if client_kwargs is not None: - resolved = self._resolve_invocation_kwargs(client_kwargs, "client_kwargs") + resolved, routed_keys = self._resolve_invocation_kwargs(client_kwargs, "client_kwargs") resolved_combined_kwargs["client_kwargs"] = resolved + routed_combined_kwargs["client_kwargs"] = sorted(routed_keys) combined_kwargs["client_kwargs"] = self._to_legacy_invocation_kwargs(resolved) - if isinstance(client_kwargs, WorkflowInvocationKwargs) or any( - isinstance(value, Mapping) for value in client_kwargs.values() + if ( + isinstance(client_kwargs, WorkflowInvocationKwargs) + or routed_keys + or any(isinstance(value, Mapping) for value in client_kwargs.values()) ): combined_kwargs[RAW_CLIENT_KWARGS_KEY] = client_kwargs self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs) self._runner.state.set(RESOLVED_WORKFLOW_RUN_KWARGS_KEY, resolved_combined_kwargs) + self._runner.state.set(ROUTED_WORKFLOW_RUN_KWARGS_KEY, routed_combined_kwargs) elif not is_continuation: self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, {}) self._runner.state.set(RESOLVED_WORKFLOW_RUN_KWARGS_KEY, {}) + self._runner.state.set(ROUTED_WORKFLOW_RUN_KWARGS_KEY, {}) self._runner.state.commit() # Commit immediately so kwargs are available # Explicitly set streaming mode per run @@ -1158,57 +1168,117 @@ def _resolve_invocation_kwargs( self, kwargs: WorkflowInvocationKwargs | Mapping[str, Any], param_name: str, - ) -> dict[str, Any]: + ) -> tuple[dict[str, Any], set[str]]: """Resolve invocation kwargs into collision-free global and executor namespaces. - Detects whether the provided kwargs dict uses per-executor targeting by checking - if any top-level key matches a known executor ID in the workflow. A legacy - ``"__global__"`` slot is separated from matched executor entries unless that name - is itself a real executor ID. If no executor ID matches, the complete dict is - treated as global application kwargs. + Detects whether a plain kwargs dict uses per-executor targeting by checking if any + top-level key matches a known executor ID in the workflow. Explicit executor kwargs + may also target a unique wrapped agent name. A legacy ``"__global__"`` slot is + separated from matched executor entries unless that name is itself a real executor + ID. If no executor ID matches a plain mapping, the complete dict is treated as + global application kwargs. Args: kwargs: The raw invocation kwargs from the caller. param_name: The parameter name (for logging), e.g. ``"function_invocation_kwargs"``. Returns: - A dict containing normalized global or per-executor mappings. + The normalized global or per-executor mappings and the caller keys routed + to executors in this workflow. """ + executor_ids = set(self.executors.keys()) + agent_name_aliases: dict[str, str] = {} + ambiguous_agent_names: set[str] = set() + from ._agent_executor import AgentExecutor + + for executor_id, executor in self.executors.items(): + if not isinstance(executor, AgentExecutor): + continue + agent_name = executor.agent.name + if not agent_name or agent_name == executor_id or agent_name in executor_ids: + continue + if agent_name in agent_name_aliases: + ambiguous_agent_names.add(agent_name) + agent_name_aliases.pop(agent_name) + elif agent_name not in ambiguous_agent_names: + agent_name_aliases[agent_name] = executor_id + + is_explicit = isinstance(kwargs, WorkflowInvocationKwargs) + provided_executor_kwargs = kwargs.executor_kwargs if is_explicit else kwargs + matched_ids = provided_executor_kwargs.keys() & executor_ids + matched_aliases: set[str] = set() + if is_explicit: + ambiguous_matches = provided_executor_kwargs.keys() & ambiguous_agent_names + if ambiguous_matches: + names = ", ".join(sorted(ambiguous_matches)) + raise ValueError( + f"Ambiguous agent name(s) in {param_name}: {names}. " + "Use the corresponding executor IDs for per-executor kwargs." + ) + matched_aliases = provided_executor_kwargs.keys() & agent_name_aliases.keys() + routed_keys = matched_ids | matched_aliases + + def normalize_executor_kwargs( + values: Mapping[str, Any], + *, + copy_values: bool = False, + resolve_aliases: bool = False, + ) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for key, value in values.items(): + executor_id = agent_name_aliases.get(key, key) if resolve_aliases else key + if executor_id in normalized: + raise ValueError(f"{param_name} targets executor '{executor_id}' by both its ID and agent name.") + normalized[executor_id] = dict(value) if copy_values else value + return normalized + if isinstance(kwargs, WorkflowInvocationKwargs): logger.info("Explicit global %s provided with executor-specific overrides.", param_name) - return { - "global_kwargs": dict(kwargs.global_kwargs), - "executor_kwargs": { - executor_id: dict(executor_kwargs) - for executor_id, executor_kwargs in kwargs.executor_kwargs.items() + return ( + { + "global_kwargs": dict(kwargs.global_kwargs), + "executor_kwargs": normalize_executor_kwargs( + kwargs.executor_kwargs, + copy_values=True, + resolve_aliases=True, + ), }, - } + routed_keys, + ) - executor_ids = set(self.executors.keys()) - matched_ids = kwargs.keys() & executor_ids - if matched_ids: + if routed_keys: executor_kwargs = dict(kwargs) - if GLOBAL_KWARGS_KEY not in executor_ids and GLOBAL_KWARGS_KEY in executor_kwargs: + if ( + GLOBAL_KWARGS_KEY not in executor_ids + and GLOBAL_KWARGS_KEY not in routed_keys + and GLOBAL_KWARGS_KEY in executor_kwargs + ): global_kwargs = executor_kwargs.pop(GLOBAL_KWARGS_KEY) logger.info( "Detected legacy mixed %s with global values and executor ID(s) %s.", param_name, - matched_ids, + routed_keys, + ) + return ( + { + "global_kwargs": global_kwargs, + "executor_kwargs": normalize_executor_kwargs(executor_kwargs), + }, + routed_keys, ) - return {"global_kwargs": global_kwargs, "executor_kwargs": executor_kwargs} logger.info( "Detected per-executor %s: executor ID(s) %s found in keys. " "All entries will be treated as per-executor.", param_name, - matched_ids, + routed_keys, ) - return {"executor_kwargs": executor_kwargs} + return {"executor_kwargs": normalize_executor_kwargs(executor_kwargs)}, routed_keys logger.info( "No executor IDs found in %s keys; treating as global kwargs for all executors.", param_name, ) - return {"global_kwargs": dict(kwargs), "executor_kwargs": {}} + return {"global_kwargs": dict(kwargs), "executor_kwargs": {}}, set() @staticmethod def _to_legacy_invocation_kwargs(resolved: dict[str, Any]) -> dict[str, Any]: diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 691dafc03b8..7c2217edc00 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -14,6 +14,8 @@ GLOBAL_KWARGS_KEY, RAW_CLIENT_KWARGS_KEY, RAW_FUNCTION_INVOCATION_KWARGS_KEY, + RESOLVED_WORKFLOW_RUN_KWARGS_KEY, + ROUTED_WORKFLOW_RUN_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY, ) from ._edge_runner import gather_cancelling_siblings_on_error @@ -38,6 +40,44 @@ logger = logging.getLogger(__name__) +def _scope_invocation_kwargs_to_subworkflow( + invocation_kwargs: WorkflowInvocationKwargs | Mapping[str, Any], + parent_routed_keys: set[str], + parent_resolved_kwargs: Mapping[str, Any] | None, +) -> WorkflowInvocationKwargs | Mapping[str, Any] | None: + """Remove kwargs entries already routed within the parent workflow.""" + if not parent_routed_keys: + return invocation_kwargs + if isinstance(invocation_kwargs, WorkflowInvocationKwargs): + executor_kwargs = { + key: value for key, value in invocation_kwargs.executor_kwargs.items() if key not in parent_routed_keys + } + if not invocation_kwargs.global_kwargs and not executor_kwargs: + return None + return WorkflowInvocationKwargs( + global_kwargs=invocation_kwargs.global_kwargs, + executor_kwargs=executor_kwargs, + ) + scoped = {key: value for key, value in invocation_kwargs.items() if key not in parent_routed_keys} + parent_global_kwargs = parent_resolved_kwargs.get("global_kwargs") if parent_resolved_kwargs else None + if ( + GLOBAL_KWARGS_KEY in scoped + and GLOBAL_KWARGS_KEY not in parent_routed_keys + and isinstance(parent_global_kwargs, Mapping) + ): + executor_kwargs = {key: value for key, value in scoped.items() if key != GLOBAL_KWARGS_KEY} + if any(not isinstance(value, Mapping) for value in executor_kwargs.values()): + return scoped + return WorkflowInvocationKwargs( + global_kwargs=cast(Mapping[str, Any], parent_global_kwargs), + executor_kwargs=cast( + Mapping[str, Mapping[str, Any]], + executor_kwargs, + ), + ) + return scoped or None + + @dataclass class ExecutionContext: """Legacy per-execution bookkeeping. @@ -380,6 +420,8 @@ async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any, A # Get kwargs from parent workflow's State to propagate to subworkflow parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) + parent_resolved_kwargs: dict[str, Any] = ctx.get_state(RESOLVED_WORKFLOW_RUN_KWARGS_KEY, {}) + parent_routed_kwargs: dict[str, list[str]] = ctx.get_state(ROUTED_WORKFLOW_RUN_KWARGS_KEY, {}) # Use the caller's raw kwargs so legacy per-executor mappings are resolved # against the child workflow's executor IDs rather than the parent's. @@ -391,7 +433,23 @@ async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any, A ) raw_value = parent_kwargs.get(raw_key) if raw_value is not None: - resolved = cast(WorkflowInvocationKwargs | Mapping[str, Any], raw_value) + routed_keys = set(parent_routed_kwargs.get(key, ())) + transparent_alias = self._transparent_agent_name_alias() if self.id in routed_keys else None + resolved = _scope_invocation_kwargs_to_subworkflow( + cast(WorkflowInvocationKwargs | Mapping[str, Any], raw_value), + self._parent_routed_keys_to_exclude(routed_keys), + cast(Mapping[str, Any] | None, parent_resolved_kwargs.get(key)), + ) + if ( + transparent_alias is not None + and isinstance(resolved, Mapping) + and not isinstance(resolved, WorkflowInvocationKwargs) + and self.id in resolved + ): + resolved = dict(resolved) + if transparent_alias in resolved: + raise ValueError(f"{key} targets executor '{transparent_alias}' by both its ID and agent name.") + resolved[transparent_alias] = resolved.pop(self.id) else: normalized: Any = parent_kwargs.get(key) if isinstance(normalized, dict): @@ -418,6 +476,32 @@ async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any, A # Process the workflow result using shared logic await self._process_workflow_result(result, ctx) + def _parent_routed_keys_to_exclude(self, routed_keys: set[str]) -> set[str]: + """Return parent-routed keys that must not be reinterpreted by the child workflow.""" + if self.id in self.workflow.executors: + return routed_keys - {self.id} + + if self._transparent_agent_name_alias() is not None: + return routed_keys - {self.id} + + return routed_keys + + def _transparent_agent_name_alias(self) -> str | None: + """Return the child executor ID uniquely identified by this wrapper's ID.""" + if self.id in self.workflow.executors: + return None + + from ._agent_executor import AgentExecutor + + matching_agent_names = [ + executor_id + for executor_id, executor in self.workflow.executors.items() + if isinstance(executor, AgentExecutor) and executor.agent.name == self.id + ] + if len(matching_agent_names) == 1: + return matching_agent_names[0] + return None + @handler async def handle_message_wrapped_request_response( self, diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index 6685c70827a..ba91dc601f8 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -1237,6 +1237,216 @@ async def test_mixed_kwargs_route_through_subworkflow() -> None: } +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +@pytest.mark.parametrize("typed", [True, False], ids=["typed", "plain"]) +async def test_parent_executor_kwargs_do_not_route_to_same_id_in_subworkflow( + kwargs_channel: str, + typed: bool, +) -> None: + """Parent-targeted kwargs do not cross into a child executor with the same ID.""" + from agent_framework import AgentExecutor, Executor, WorkflowBuilder, WorkflowContext, handler + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + class _FanOut(Executor): + @handler + async def fan_out(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + parent_agent = _KwargsCapturingAgent(name="parent_agent") + child_collision = _KwargsCapturingAgent(name="child_collision") + child_target = _KwargsCapturingAgent(name="child_target") + child = SequentialBuilder( + participants=[ + AgentExecutor(child_collision, id="shared"), + AgentExecutor(child_target, id="child"), + ] + ).build() + dispatcher = _FanOut(id="dispatcher") + parent_executor = AgentExecutor(parent_agent, id="shared") + nested_executor = WorkflowExecutor(child, id="nested") + parent = ( + WorkflowBuilder(start_executor=dispatcher) + .add_fan_out_edges(dispatcher, [parent_executor, nested_executor]) + .build() + ) + executor_kwargs = { + "shared": {"api_key": "parent-only"}, + "child": {"api_key": "child-only"}, + } + invocation_kwargs: Mapping[str, Any] | WorkflowInvocationKwargs + invocation_kwargs = WorkflowInvocationKwargs(executor_kwargs=executor_kwargs) if typed else executor_kwargs + + if kwargs_channel == "function_invocation_kwargs": + await parent.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await parent.run("test", client_kwargs=invocation_kwargs) + + assert parent_agent.captured_kwargs[0].get(kwargs_channel) == {"api_key": "parent-only"} + assert child_collision.captured_kwargs[0].get(kwargs_channel) == ({} if typed else None) + assert child_target.captured_kwargs[0].get(kwargs_channel) == {"api_key": "child-only"} + + +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_non_mapping_parent_executor_kwargs_do_not_become_child_globals( + kwargs_channel: str, + caplog: "LogCaptureFixture", +) -> None: + """Malformed parent-targeted values remain scoped to the parent graph.""" + from agent_framework import AgentExecutor, Executor, WorkflowBuilder, WorkflowContext, handler + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + class _FanOut(Executor): + @handler + async def fan_out(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + parent_agent = _KwargsCapturingAgent(name="parent") + child_agent = _KwargsCapturingAgent(name="child") + child = SequentialBuilder(participants=[child_agent]).build() + dispatcher = _FanOut(id="dispatcher") + parent_executor = AgentExecutor(parent_agent, id="parent") + nested_executor = WorkflowExecutor(child, id="nested") + parent = ( + WorkflowBuilder(start_executor=dispatcher) + .add_fan_out_edges(dispatcher, [parent_executor, nested_executor]) + .build() + ) + invocation_kwargs = {"parent": "invalid"} + + if kwargs_channel == "function_invocation_kwargs": + await parent.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await parent.run("test", client_kwargs=invocation_kwargs) + + assert parent_agent.captured_kwargs[0].get(kwargs_channel) is None + assert child_agent.captured_kwargs[0].get(kwargs_channel) is None + assert any("expected a dict for its kwargs" in record.message for record in caplog.records) + + +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +@pytest.mark.parametrize("child_kwargs", [{"child_only": True}, None, "invalid"]) +async def test_legacy_mixed_global_survives_parent_key_filtering( + kwargs_channel: str, + child_kwargs: Mapping[str, Any] | str | None, + caplog: "LogCaptureFixture", +) -> None: + """A legacy global slot retains its meaning after parent-targeted entries are removed.""" + from agent_framework import AgentExecutor, Executor, WorkflowBuilder, WorkflowContext, handler + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + class _FanOut(Executor): + @handler + async def fan_out(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + parent_agent = _KwargsCapturingAgent(name="parent") + child_agent = _KwargsCapturingAgent(name="child") + child = SequentialBuilder(participants=[child_agent]).build() + dispatcher = _FanOut(id="dispatcher") + parent_executor = AgentExecutor(parent_agent, id="parent") + nested_executor = WorkflowExecutor(child, id="nested") + parent = ( + WorkflowBuilder(start_executor=dispatcher) + .add_fan_out_edges(dispatcher, [parent_executor, nested_executor]) + .build() + ) + invocation_kwargs = { + "__global__": {"shared": "global"}, + "parent": {"parent_only": True}, + "child": child_kwargs, + } + + if kwargs_channel == "function_invocation_kwargs": + await parent.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await parent.run("test", client_kwargs=invocation_kwargs) + + assert parent_agent.captured_kwargs[0].get(kwargs_channel) == { + "shared": "global", + "parent_only": True, + } + if isinstance(child_kwargs, Mapping): + assert child_agent.captured_kwargs[0].get(kwargs_channel) == { + "shared": "global", + "child_only": True, + } + elif child_kwargs is None: + assert child_agent.captured_kwargs[0].get(kwargs_channel) == {"shared": "global"} + else: + assert child_agent.captured_kwargs[0].get(kwargs_channel) is None + assert any("expected a dict for its kwargs" in record.message for record in caplog.records) + + +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_transparent_subworkflow_preserves_only_its_own_parent_key(kwargs_channel: str) -> None: + """A transparent wrapper preserves its own key without leaking unrelated parent keys.""" + from agent_framework import AgentExecutor, Executor, WorkflowBuilder, WorkflowContext, handler + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + class _FanOut(Executor): + @handler + async def fan_out(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + wrapped_agent = _KwargsCapturingAgent(name="wrapped") + internal_collision = _KwargsCapturingAgent(name="internal_collision") + outer_internal = _KwargsCapturingAgent(name="outer_internal") + child = SequentialBuilder( + participants=[ + AgentExecutor(wrapped_agent, id="wrapped"), + AgentExecutor(internal_collision, id="internal"), + ] + ).build() + dispatcher = _FanOut(id="dispatcher") + nested_executor = WorkflowExecutor(child, id="wrapped") + outer_internal_executor = AgentExecutor(outer_internal, id="internal") + parent = ( + WorkflowBuilder(start_executor=dispatcher) + .add_fan_out_edges(dispatcher, [nested_executor, outer_internal_executor]) + .build() + ) + invocation_kwargs = { + "wrapped": {"wrapper_only": True}, + "internal": {"outer_only": True}, + } + + if kwargs_channel == "function_invocation_kwargs": + await parent.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await parent.run("test", client_kwargs=invocation_kwargs) + + assert wrapped_agent.captured_kwargs[0].get(kwargs_channel) == {"wrapper_only": True} + assert internal_collision.captured_kwargs[0].get(kwargs_channel) is None + assert outer_internal.captured_kwargs[0].get(kwargs_channel) == {"outer_only": True} + + +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_transparent_subworkflow_preserves_agent_name_alias(kwargs_channel: str) -> None: + """A wrapper key routes through a unique child agent-name alias.""" + from agent_framework import AgentExecutor + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + wrapped_agent = _KwargsCapturingAgent(name="wrapped") + child = SequentialBuilder( + participants=[ + AgentExecutor(wrapped_agent, id="wrapped_executor"), + ] + ).build() + parent = SequentialBuilder( + participants=[ + WorkflowExecutor(child, id="wrapped"), + ] + ).build() + invocation_kwargs = {"wrapped": {"wrapper_only": True}} + + if kwargs_channel == "function_invocation_kwargs": + await parent.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await parent.run("test", client_kwargs=invocation_kwargs) + + assert wrapped_agent.captured_kwargs[0].get(kwargs_channel) == {"wrapper_only": True} + + @pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) async def test_legacy_mixed_kwargs_route_through_subworkflow(kwargs_channel: str) -> None: """A child graph reclassifies preserved legacy mixed input against its executor IDs.""" @@ -1486,6 +1696,82 @@ async def test_per_executor_function_invocation_kwargs_routes_to_correct_agent() assert agent2.captured_kwargs[0].get("function_invocation_kwargs") == {"tool_param": "value_for_agent2"} +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_explicit_agent_name_routes_to_executor_id(kwargs_channel: str) -> None: + """An explicitly executor-scoped agent name aliases its executor ID.""" + from agent_framework import AgentExecutor + + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder( + participants=[ + AgentExecutor(agent1, id="executor1"), + AgentExecutor(agent2, id="executor2"), + ] + ).build() + executor_kwargs = { + "agent1": {"api_key": "agent1-only"}, + "agent2": {"api_key": "agent2-only"}, + } + invocation_kwargs = WorkflowInvocationKwargs(executor_kwargs=executor_kwargs) + + if kwargs_channel == "function_invocation_kwargs": + await workflow.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await workflow.run("test", client_kwargs=invocation_kwargs) + + assert agent1.captured_kwargs[0].get(kwargs_channel) == {"api_key": "agent1-only"} + assert agent2.captured_kwargs[0].get(kwargs_channel) == {"api_key": "agent2-only"} + + +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_plain_agent_name_key_remains_global_with_explicit_executor_id(kwargs_channel: str) -> None: + """A plain application kwarg matching an agent name does not trigger executor routing.""" + from agent_framework import AgentExecutor + + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder( + participants=[ + AgentExecutor(agent1, id="executor1"), + AgentExecutor(agent2, id="executor2"), + ] + ).build() + invocation_kwargs = {"agent1": {"api_key": "shared"}} + + if kwargs_channel == "function_invocation_kwargs": + await workflow.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await workflow.run("test", client_kwargs=invocation_kwargs) + + assert agent1.captured_kwargs[0].get(kwargs_channel) == invocation_kwargs + assert agent2.captured_kwargs[0].get(kwargs_channel) == invocation_kwargs + + +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_explicit_global_agent_name_alias_targets_executor(kwargs_channel: str) -> None: + """An agent named __global__ can be explicitly targeted through a different executor ID.""" + from agent_framework import AgentExecutor + + global_agent = _KwargsCapturingAgent(name="__global__") + sibling = _KwargsCapturingAgent(name="sibling") + workflow = SequentialBuilder( + participants=[ + AgentExecutor(global_agent, id="global_agent"), + sibling, + ] + ).build() + invocation_kwargs = WorkflowInvocationKwargs(executor_kwargs={"__global__": {"targeted": True}}) + + if kwargs_channel == "function_invocation_kwargs": + await workflow.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await workflow.run("test", client_kwargs=invocation_kwargs) + + assert global_agent.captured_kwargs[0].get(kwargs_channel) == {"targeted": True} + assert sibling.captured_kwargs[0].get(kwargs_channel) == {} + + async def test_global_and_per_executor_function_invocation_kwargs_are_merged() -> None: """Global function kwargs are merged with executor-specific overrides.""" agent1 = _KwargsCapturingAgent(name="agent1") From 2055e666e1ac356396545ec0a4b2487c9e68e0af Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 24 Sep 2026 08:59:28 +0000 Subject: [PATCH 38/42] Python: settle fatal unknown function calls (#8640) --- .../specs/004-python-function-calling-loop.md | 35 ++--- .../packages/core/agent_framework/_tools.py | 77 ++++++---- .../core/test_function_invocation_logic.py | 138 +++++++++++++++++- 3 files changed, 199 insertions(+), 51 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 96b41d6d38f..ccfc2644614 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -383,24 +383,25 @@ that manually replay messages own the equivalent rule: do not resend an approval must treat the exception as an explicit instruction to discard those calls and never execute them. Caller cancellation remains cancellation rather than becoming this provider signal. - Every actionable local `function_call` produces exactly one terminal `function_result`, unless execution pauses - for a new user-input request or the run is aborted by `MiddlewareFailure`. + for a new user-input request or the run is aborted by a fail-closed error. - An ordinary exception raised by function middleware or a tool body becomes one terminal error `function_result` - and the loop continues; `MiddlewareFailure` is the loop's only fail-closed escape: it is never converted into a + and the loop continues. `MiddlewareFailure` is the execution-time fail-closed escape: it is never converted into a tool result, the in-flight parallel batch is cancelled, no further tool call starts, no further model turn is - consumed, and the exception propagates to the caller (for streaming runs, when the stream is consumed). On a - service-managed conversation the loop first settles the aborted batch — one error `function_result` per dangling - call (approval-response wrappers unwrap to their underlying calls; hosted-tool approvals are left to their own - provider protocol), submitted with `tool_choice="none"` in a single extra request — so the hosted thread is not - left ending in unresolved function calls that the service would reject on the session's next request; the - persisted continuation then advances to the settlement response (for response-ID continuations the settled - endpoint is the new handle; for conversation-object ids the advance is a no-op) and the settlement response is - otherwise discarded. Settlement covers the approval-resolution phase too: a fatal abort while an approved tool is - replayed settles the original, already-persisted calls. Without a service-managed conversation no extra request - is made. Batch - cancellation is cooperative: an async sibling stops at its next suspension point, while a synchronous tool body - already executing in a worker thread cannot be interrupted and may complete its side effects — its result is - discarded either way and never reaches the transcript, the model, or history. Middleware must not catch - `MiddlewareFailure` — swallowing it converts a fail-closed abort back into a running, possibly unguarded loop. + consumed, and the exception propagates to the caller (for streaming runs, when the stream is consumed). A + configured fatal unknown call likewise raises `KeyError` and propagates, but complete-batch classification detects + it before any call executes. On a service-managed conversation the loop first settles either aborted batch — one + error `function_result` per dangling call (approval-response wrappers unwrap to their underlying calls; hosted-tool + approvals are left to their own provider protocol), submitted with `tool_choice="none"` in a single extra request — + so the hosted thread is not left ending in unresolved function calls that the service would reject on the session's + next request; the persisted continuation then advances to the settlement response (for response-ID continuations + the settled endpoint is the new handle; for conversation-object ids the advance is a no-op) and the settlement + response is otherwise discarded. Settlement covers the approval-resolution phase too: a fatal abort while an + approved tool is replayed settles the original, already-persisted calls. Without a service-managed conversation no + extra request is made. Batch cancellation is cooperative: an async sibling stops at its next suspension point, + while a synchronous tool body already executing in a worker thread cannot be interrupted and may complete its side + effects — its result is discarded either way and never reaches the transcript, the model, or history. Middleware + must not catch `MiddlewareFailure` — swallowing it converts a fail-closed abort back into a running, possibly + unguarded loop. - `Content.exception` is host-internal diagnostic state. Default `Content.to_dict()` and nested response serialization replace it with a fixed non-sensitive failure marker, while the original field remains directly available to trusted local code. Remote protocol serializers use the marker only for status and use the channel-visible `result` or `items` for output text. `include_detailed_errors=False` keeps the channel-visible @@ -677,7 +678,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Approved middleware repair | Approval binds to the normalized middleware-entry representation, so ordinary Pydantic coercion still completes in one approval round. A changed approval-bound call executes zero times under the old grant, returns a persisted occurrence-bound replacement request in both response modes, and executes once only after the replacement is approved. Recursive type-aware, float-bit-exact comparison treats booleans and numbers, and positive and negative zero, as distinct while keeping unchanged NaNs stable. Opaque mutable normalized values fail closed before approval authority is established. The same replacement rule applies when middleware short-circuits instead of calling the tool. Security expansion preserves approval-visible placeholders. | `test_approved_coercing_arguments_execute_without_replacement`, `test_approved_argument_repair_requires_replacement_approval`, `test_approved_argument_repair_short_circuit_requires_replacement_approval`, `test_approval_snapshot_distinguishes_exact_values`, `test_approval_rejects_opaque_mutable_validator_output`, `packages/core/tests/test_security.py::TestVariableArgumentPolicy::test_hidden_argument_resolution_does_not_require_reapproval` | | Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` | | Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` | -| Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents | +| Unknown call handling | Configured false returns an error result; configured true raises before any call executes. A service-managed continuation settles every call in the aborted batch and advances to the settlement response before the `KeyError` propagates; without one, including local per-service-call history persistence, no extra request is made. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents, `test_fatal_unknown_settles_service_conversation`, `test_fatal_unknown_with_local_history_makes_no_settlement_request` | | Middleware termination | Normal non-approval loop stops without a second model call. | `test_terminate_loop_single_function_call`, `test_terminate_loop_multiple_function_calls_one_terminates`, `test_terminate_loop_streaming_single_function_call` | | Middleware failure (fatal) | `MiddlewareFailure` from function middleware or a tool body propagates to the caller without becoming a tool result; the tool does not execute (pre-invocation) or its result never feeds another model call (post-invocation); the cause chain is preserved; ordinary exceptions still become tool-error results and the loop continues. | `packages/core/tests/core/test_middleware_with_agent.py::TestMiddlewareFailure::test_failure_before_tool_aborts_run`, `test_failure_after_tool_aborts_run_before_next_model_turn`, `test_failure_cause_chain_reaches_caller`, `test_failure_from_tool_escapes_without_middleware`, `test_failure_streaming_reaches_stream_consumer`, `test_ordinary_exception_still_becomes_tool_error` | | Middleware failure batch cancellation | A fatal signal fails the whole parallel batch: in-flight sibling tool invocations are cancelled and awaited before the failure propagates. Cancellation is cooperative — an async sibling stops at its next suspension point; a synchronous tool body already executing in a worker thread cannot be interrupted and may complete its side effects, but its result is discarded and never reaches the transcript, the model, or history, and failure propagation is not delayed behind it. | `TestMiddlewareFailure::test_failure_cancels_concurrent_sibling_tool`, `test_failure_with_sync_sibling_discards_late_result` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index bfde7930c10..98dfd9ad455 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -142,6 +142,10 @@ def __init__(self, message: str, *, redacted_message: str | None = None) -> None self.redacted_message = redacted_message or message +class _UnknownFunctionCallError(KeyError): + """A configured fatal unknown function call.""" + + class _FunctionArgumentsChangedAfterApproval(Exception): """Signal that middleware changed an approval-bound invocation.""" @@ -2440,7 +2444,7 @@ async def _try_execute_function_call_groups( unknown_call_found = True unknown_call_name = function_name if unknown_call_found: - raise KeyError(f'Error: Requested function "{unknown_call_name}" not found.') + raise _UnknownFunctionCallError(f'Error: Requested function "{unknown_call_name}" not found.') if requires_approval: # Surface approval and Host-owned pauses in model order. Session-backed # executable siblings remain hidden until the approval batch resumes. @@ -4821,30 +4825,33 @@ async def _settle_dangling_service_function_calls( tokenizer: TokenizerProtocol | None, invocation_session: AgentSession | None, response_conversation_id: str | None = None, + error_result: str = "Error: Tool execution was aborted by middleware before a result was produced.", + error_exception: str = "MiddlewareFailure", ) -> None: """Resolve an aborted batch's function calls on a service-managed conversation. - When ``MiddlewareFailure`` aborts a tool batch, the local run raises before - any result exists — but on a service-managed conversation the continuation - state (``session.service_session_id``) was already persisted, so the hosted - thread ends with unresolved ``function_call`` items and OpenAI-style - continuations reject the session's next request (missing tool output). Settle - the thread by submitting one error ``function_result`` per dangling call - (approval-response wrappers are unwrapped to their underlying calls; - hosted-tool approvals are left to their own provider protocol) with - ``tool_choice="none"`` so no new calls are requested, then advance the - persisted continuation to the settlement response: for response-ID - continuations the settlement response is the first endpoint whose chain - includes the synthetic outputs, so the next run must start from it (for - conversation-object ids the advance is a no-op). The settlement response is - otherwise discarded and the run still fails with the original - ``MiddlewareFailure``. Everything here is best-effort — a settlement failure - is logged and never masks the abort. Costs one extra request, only on the - failure path and only when a service-managed conversation is in play. + When a fail-closed error aborts a tool batch, the local run raises before any + result exists — but on a service-managed conversation the continuation state + (``session.service_session_id``) was already persisted, so the hosted thread + ends with unresolved ``function_call`` items and OpenAI-style continuations + reject the session's next request (missing tool output). Settle the thread by + submitting one error ``function_result`` per dangling call (approval-response + wrappers are unwrapped to their underlying calls; hosted-tool approvals are + left to their own provider protocol) with ``tool_choice="none"`` so no new + calls are requested, then advance the persisted continuation to the settlement + response: for response-ID continuations the settlement response is the first + endpoint whose chain includes the synthetic outputs, so the next run must + start from it (for conversation-object ids the advance is a no-op). The + settlement response is otherwise discarded and the run still fails with the + original error. Everything here is best-effort — a settlement failure is + logged and never masks the abort. Costs one extra request, only on the failure + path and only when a service-managed conversation is in play. """ + from ._sessions import is_local_history_conversation_id from ._types import ChatResponse, Content, Message - if response_conversation_id is None and not options.get("conversation_id"): + continuation_id = response_conversation_id or cast("str | None", options.get("conversation_id")) + if continuation_id is None or is_local_history_conversation_id(continuation_id): return try: error_results: list[Content] = [] @@ -4857,8 +4864,8 @@ async def _settle_dangling_service_function_calls( error_results.append( Content.from_function_result( call_id=underlying_call.call_id, - result="Error: Tool execution was aborted by middleware before a result was produced.", - exception="MiddlewareFailure", + result=error_result, + exception=error_exception, additional_properties=underlying_call.additional_properties, ) ) @@ -5039,11 +5046,10 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non invocation_session=invocation_session, approval_session_is_authoritative=approval_session_is_authoritative, ) - except MiddlewareFailure: - # Fail-closed abort: before propagating, settle the batch's calls on a - # service-managed conversation and advance the persisted continuation - # to the settled endpoint (best-effort — a settlement failure never - # masks the abort). + except (MiddlewareFailure, _UnknownFunctionCallError) as exc: + # Before propagating a fail-closed abort, settle the batch's calls on a + # service-managed conversation and advance the persisted continuation. + is_unknown_call = isinstance(exc, _UnknownFunctionCallError) await self._settle_dangling_service_function_calls( super_get_response=super_get_response, function_calls=_extract_function_calls(response), @@ -5053,6 +5059,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non tokenizer=tokenizer, invocation_session=invocation_session, response_conversation_id=response.conversation_id, + error_result=( + f"Error: Tool execution was aborted before a result was produced. {exc.args[0]}" + if is_unknown_call + else "Error: Tool execution was aborted by middleware before a result was produced." + ), + error_exception="KeyError" if is_unknown_call else "MiddlewareFailure", ) raise total_function_calls = _record_function_calls( @@ -5312,10 +5324,9 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non invocation_session=invocation_session, approval_session_is_authoritative=approval_session_is_authoritative, ) - except MiddlewareFailure: - # See the non-streaming loop: settle a service-managed conversation's - # dangling calls and advance the persisted continuation before - # propagating the fail-closed abort (best-effort). + except (MiddlewareFailure, _UnknownFunctionCallError) as exc: + # See the non-streaming loop: settle and advance before propagating. + is_unknown_call = isinstance(exc, _UnknownFunctionCallError) await self._settle_dangling_service_function_calls( super_get_response=super_get_response, function_calls=_extract_function_calls(response), @@ -5325,6 +5336,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non tokenizer=tokenizer, invocation_session=invocation_session, response_conversation_id=response.conversation_id, + error_result=( + f"Error: Tool execution was aborted before a result was produced. {exc.args[0]}" + if is_unknown_call + else "Error: Tool execution was aborted by middleware before a result was produced." + ), + error_exception="KeyError" if is_unknown_call else "MiddlewareFailure", ) raise errors_in_a_row = function_processing.errors_in_a_row diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 210f291f1c9..ad63bf5d937 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3525,6 +3525,7 @@ def known_func(arg1: str) -> str: ) assert exec_counter == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] @pytest.mark.parametrize("unknown_first", [True, False], ids=["unknown-first", "unknown-last"]) @@ -3572,6 +3573,133 @@ def safe_func() -> str: assert approval_calls == safe_calls == 0 +@pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"]) +async def test_fatal_unknown_settles_service_conversation( + chat_client_base: SupportsChatGetResponse, + stream: bool, +) -> None: + """A fatal unknown call settles every dangling local call before propagating.""" + requests: list[dict[str, Any]] = [] + known_calls = 0 + + @chat_middleware + async def record_requests(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + requests.append({ + "conversation_id": (context.options or {}).get("conversation_id"), + "messages": list(context.messages), + "tool_choice": (context.options or {}).get("tool_choice"), + }) + await call_next() + + @tool(name="known_function", approval_mode="never_require") + def known_func() -> str: + nonlocal known_calls + known_calls += 1 + return "known" + + function_calls = [ + Content.from_function_call(call_id="unknown", name="unknown_function", arguments={}), + Content.from_function_call(call_id="known", name="known_function", arguments={}), + ] + settlement_response = ChatResponse( + messages=Message(role="assistant", contents=["settled"]), + conversation_id="resp_2", + ) + if stream: + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ + ChatResponseUpdate( + contents=function_calls, + role="assistant", + conversation_id="resp_1", + ) + ] + ] + chat_client_base.run_responses = [settlement_response] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + else: + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message(role="assistant", contents=function_calls), + conversation_id="resp_1", + ), + settlement_response, + ] + chat_client_base.chat_middleware = [record_requests] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.function_invocation_configuration["terminate_on_unknown_calls"] = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + session = AgentSession() + + with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'): + if stream: + async for _ in chat_client_base.get_response( # type: ignore[call-overload] # pyrefly: ignore[no-matching-overload] # ty: ignore[no-matching-overload] + "hello", # type: ignore[arg-type] + options={"tool_choice": "auto", "tools": [known_func]}, + stream=True, + client_kwargs={"session": session}, + ): + pass + else: + await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], + options={"tool_choice": "auto", "tools": [known_func]}, + client_kwargs={"session": session}, + ) + + assert known_calls == 0 + assert len(requests) == 2 + settlement = requests[1] + assert settlement["conversation_id"] == "resp_1" + assert settlement["tool_choice"] == "none" + settlement_results = [ + content + for message in settlement["messages"] + for content in message.contents + if content.type == "function_result" + ] + assert [(result.call_id, result.exception) for result in settlement_results] == [ + ("unknown", "KeyError"), + ("known", "KeyError"), + ] + assert all("unknown_function" in (result.result or "") for result in settlement_results) + assert session.service_session_id == "resp_2" + + +@pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"]) +async def test_fatal_unknown_with_local_history_makes_no_settlement_request( + chat_client_base: SupportsChatGetResponse, + stream: bool, +) -> None: + """The local history sentinel must not be treated as a service continuation.""" + from agent_framework import InMemoryHistoryProvider + + unknown_call = Content.from_function_call(call_id="unknown", name="unknown_function", arguments={}) + if stream: + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ChatResponseUpdate(contents=[unknown_call], role="assistant")] + ] + else: + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=[unknown_call])) + ] + chat_client_base.function_invocation_configuration["terminate_on_unknown_calls"] = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + agent = Agent( + client=chat_client_base, + tools=[FunctionTool(name="known_function", func=lambda: "known")], + context_providers=[InMemoryHistoryProvider()], + require_per_service_call_history_persistence=True, + ) + session = AgentSession() + + with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'): + if stream: + async for _ in agent.run("hello", session=session, stream=True): + pass + else: + await agent.run("hello", session=session) + + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert session.service_session_id is None + + @pytest.mark.parametrize("approval_first", [True, False], ids=["approval-first", "host-first"]) async def test_mixed_batch_returns_approval_and_host_pause_in_model_order(approval_first: bool) -> None: """Approval and Host-owned calls should pause together without executing.""" @@ -8084,10 +8212,9 @@ def known_func(arg1: str) -> str: assert exec_counter == 0 # Known function not executed -@pytest.mark.skip(reason="Failsafe behavior needs investigation in unified API") async def test_streaming_function_invocation_config_terminate_on_unknown_calls_true( chat_client_base: SupportsChatGetResponse, -): +) -> None: """Test that terminate_on_unknown_calls=True stops execution on unknown functions in streaming mode.""" exec_counter = 0 @@ -8113,12 +8240,15 @@ def known_func(arg1: str) -> str: # Should raise an exception when encountering an unknown function with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'): - async for _ in chat_client_base.get_response( # type: ignore[attr-defined] # pyrefly: ignore[not-iterable] # ty: ignore[not-iterable] - [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [known_func]} + async for _ in chat_client_base.get_response( # type: ignore[call-overload] # pyrefly: ignore[no-matching-overload] # ty: ignore[no-matching-overload] + "hello", # type: ignore[arg-type] + options={"tool_choice": "auto", "tools": [known_func]}, + stream=True, ): pass assert exec_counter == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] async def test_streaming_function_invocation_config_include_detailed_errors_true( From 62337887116c604ff542dbec44dd4cddd814295c Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 24 Sep 2026 09:01:24 +0000 Subject: [PATCH 39/42] Python: fix Foundry background local tool replay (#8670) * Python: fix Foundry background stateless tool replay * Python: harden stateless reasoning replay validation --- .../agent_framework_foundry/_chat_client.py | 85 +++++- .../tests/foundry/test_foundry_chat_client.py | 250 +++++++++++++++++- .../agent_framework_openai/_chat_client.py | 46 +++- .../tests/openai/test_openai_chat_client.py | 48 ++++ 4 files changed, 415 insertions(+), 14 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 3f0d519c025..a064839cf70 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -5,10 +5,11 @@ import logging import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast from agent_framework import ( ChatMiddlewareLayer, + ChatResponse, ChatResponseUpdate, Content, FunctionInvocationConfiguration, @@ -84,6 +85,16 @@ AzureTokenProvider = Callable[[], str | Awaitable[str]] AzureCredentialTypes = TokenCredential | AsyncTokenCredential +_FOUNDRY_REASONING_REPLAY_ITEM_KEY = "__foundry_reasoning_replay_item__" +_FOUNDRY_REASONING_REPLAY_FIELDS = frozenset({ + "type", + "id", + "response_id", + "status", + "summary", + "content", + "encrypted_content", +}) class FoundrySettings(TypedDict, total=False): @@ -270,6 +281,73 @@ async def _prepare_options( run_options.pop("include") return run_options + @override + def _prepare_provider_reasoning_item_for_openai( + self, + reasoning_id: str, + contents: Sequence[Content], + ) -> dict[str, Any] | None: + for content in reversed(contents): + raw_item = content.additional_properties.get(_FOUNDRY_REASONING_REPLAY_ITEM_KEY) + if isinstance(raw_item, Mapping): + payload = self._prepare_foundry_reasoning_replay_item(raw_item) + if payload is not None and payload["id"] == reasoning_id: + return payload + return None + + @classmethod + def _prepare_foundry_reasoning_replay_item(cls, item: Any) -> dict[str, Any] | None: + payload = cls._serialize_provider_payload(item) + if not isinstance(payload, Mapping): + return None + typed_payload = cast("Mapping[str, Any]", payload) + if typed_payload.get("type") != "reasoning": + return None + reasoning_id = typed_payload.get("id") + if not isinstance(reasoning_id, str) or not reasoning_id: + return None + response_id = typed_payload.get("response_id") + if not isinstance(response_id, str) or not response_id: + return None + if not isinstance(typed_payload.get("summary"), list) or not isinstance(typed_payload.get("content"), list): + return None + return {key: typed_payload[key] for key in _FOUNDRY_REASONING_REPLAY_FIELDS if key in typed_payload} + + @classmethod + def _attach_foundry_reasoning_replay_item(cls, contents: list[Content], item: Any) -> None: + payload = cls._prepare_foundry_reasoning_replay_item(item) + if payload is None: + return + + reasoning_id = cast(str, payload["id"]) + for content in reversed(contents): + if content.type == "text_reasoning" and content.id == reasoning_id: + content.additional_properties[_FOUNDRY_REASONING_REPLAY_ITEM_KEY] = payload + return + + contents.append( + Content.from_text_reasoning( + id=reasoning_id, + text="", + raw_representation=item, + additional_properties={_FOUNDRY_REASONING_REPLAY_ITEM_KEY: payload}, + ) + ) + + @override + def _parse_response_from_openai( + self, + response: Any, + options: dict[str, Any], + ) -> ChatResponse: + chat_response = super()._parse_response_from_openai(response, options) + if chat_response.messages: + for item in getattr(response, "output", ()): + if getattr(item, "type", None) != "reasoning": + continue + self._attach_foundry_reasoning_replay_item(chat_response.messages[0].contents, item) + return chat_response + @override def _check_model_presence(self, options: dict[str, Any]) -> None: if not options.get("model"): @@ -297,7 +375,10 @@ def _parse_chunk_from_openai( update = try_parse_oauth_consent_event(event, self.model) if update is not None: return update - return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) + update = super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) + if event.type == "response.output_item.done" and getattr(event.item, "type", None) == "reasoning": + self._attach_foundry_reasoning_replay_item(update.contents, event.item) + return update async def configure_azure_monitor( self, diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 480242fd017..abfc37c6827 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -28,7 +28,12 @@ from agent_framework._sessions import AgentSession from agent_framework._telemetry import get_user_agent, mark_feature_used from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException -from agent_framework_openai import OpenAIChatClient, OpenAIContentFilterException +from agent_framework_openai import ( + OpenAIChatClient, + OpenAIChatOptions, + OpenAIContentFilterException, + OpenAIContinuationToken, +) from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.exceptions import ResourceNotFoundError @@ -53,6 +58,16 @@ class OutputStruct(BaseModel): weather: str | None = None +class FoundryReasoningItem(BaseModel): + """Minimal Foundry reasoning item used to verify provider-native replay.""" + + type: str = "reasoning" + id: str + response_id: str + summary: list[Any] + content: list[Any] + + @pytest.mark.parametrize( "url", [ @@ -526,6 +541,186 @@ async def create_response(**kwargs: Any) -> Any: assert response.response_id == "response_123" +async def test_stateless_replay_uses_foundry_reasoning_item_without_encrypted_content() -> None: + reasoning_item = FoundryReasoningItem( + id="rs_foundry", + response_id="resp_background", + summary=[], + content=[], + ) + function_call = MagicMock( + type="function_call", + id="fc_foundry", + call_id="call_foundry", + arguments='{"step":1}', + status="completed", + ) + function_call.name = "lookup_probe" + mock_response = MagicMock( + id="resp_background", + model="test-model", + created_at=1000000000, + metadata={}, + output_parsed=None, + output=[reasoning_item, function_call], + usage=None, + conversation=None, + status="completed", + incomplete_details=None, + ) + + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + response = client._parse_response_from_openai(mock_response, options={"store": False}) + + messages = [ + Message(role="user", contents=["Run the probe."]), + *response.messages, + Message( + role="tool", + contents=[Content.from_function_result(call_id="call_foundry", result="probe-result-step-1")], + ), + ] + _, run_options, _ = await client._prepare_request(messages, {"store": False}) + + assert run_options["input"] == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Run the probe."}], + }, + { + "type": "reasoning", + "id": "rs_foundry", + "response_id": "resp_background", + "summary": [], + "content": [], + }, + { + "call_id": "call_foundry", + "id": "fc_foundry", + "type": "function_call", + "name": "lookup_probe", + "arguments": '{"step":1}', + "status": "completed", + }, + { + "call_id": "call_foundry", + "type": "function_call_output", + "output": "probe-result-step-1", + }, + ] + + +def test_streaming_stateless_replay_prefers_completed_foundry_reasoning_item() -> None: + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + added = MagicMock() + added.type = "response.output_item.added" + added.output_index = 0 + added.item = FoundryReasoningItem( + id="rs_foundry", + response_id="resp_incomplete", + summary=[], + content=[], + ) + done = MagicMock() + done.type = "response.output_item.done" + done.output_index = 0 + done.item = FoundryReasoningItem( + id="rs_foundry", + response_id="resp_complete", + summary=[], + content=[], + ) + + response = ChatResponse.from_updates([ + client._parse_chunk_from_openai(added, {}, {}), + client._parse_chunk_from_openai(done, {}, {}), + ]) + + reasoning_items = client._prepare_reasoning_items_for_openai(response.messages) + + assert reasoning_items == { + "rs_foundry": { + "type": "reasoning", + "id": "rs_foundry", + "response_id": "resp_complete", + "summary": [], + "content": [], + } + } + + +async def test_stateless_replay_rejects_incomplete_foundry_reasoning_item() -> None: + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + messages = [ + Message( + role="assistant", + contents=[ + Content.from_text_reasoning(id="rs_incomplete", text=""), + Content.from_function_call( + call_id="call_incomplete", + name="lookup_probe", + arguments='{"step":1}', + ), + ], + ), + Message( + role="tool", + contents=[Content.from_function_result(call_id="call_incomplete", result="probe-result-step-1")], + ), + ] + + with pytest.raises( + ChatClientInvalidRequestException, + match="rs_incomplete.*required reasoning replay data is missing or invalid", + ): + await client._prepare_request(messages, {"store": False}) + + +async def test_stateless_replay_rejects_malformed_stored_foundry_reasoning_item() -> None: + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + messages = [ + Message( + role="assistant", + contents=[ + Content.from_text_reasoning( + id="rs_malformed", + text="", + additional_properties={ + "__foundry_reasoning_replay_item__": { + "id": "rs_malformed", + } + }, + ), + Content.from_function_call( + call_id="call_malformed", + name="lookup_probe", + arguments='{"step":1}', + ), + ], + ), + Message( + role="tool", + contents=[Content.from_function_result(call_id="call_malformed", result="probe-result-step-1")], + ), + ] + + with pytest.raises( + ChatClientInvalidRequestException, + match="rs_malformed.*required reasoning replay data is missing or invalid", + ): + await client._prepare_request(messages, {"store": False}) + + async def test_web_search_tool_with_location() -> None: mock_openai_client = _make_mock_openai_client() project_client = MagicMock() @@ -1144,6 +1339,59 @@ async def test_integration_options( assert "location" in response.value +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@pytest.mark.parametrize("store", [True, False]) +@_with_foundry_debug() +async def test_integration_background_local_tool_loop(store: bool) -> None: + executions: list[int] = [] + + @tool(approval_mode="never_require") + def lookup_probe(step: int) -> str: + """Return a deterministic marker for the requested step.""" + executions.append(step) + return f"probe-result-step-{step}" + + client = FoundryChatClient(credential=cast(Any, AzureCliCredential())) + client.function_invocation_configuration["max_iterations"] = 4 + agent = Agent( + client=client, + name=f"background_tool_probe_store_{str(store).lower()}", + instructions=( + "Call lookup_probe with step=1 exactly once. " + "After receiving the tool result, return that exact result and do not call any tool again." + ), + tools=[lookup_probe], + default_options=OpenAIChatOptions( + store=store, + allow_multiple_tool_calls=False, + tool_choice={"mode": "required", "required_function_name": "lookup_probe"}, + ), + ) + session = AgentSession() + + response = await agent.run( + "Run the probe.", + session=session, + options=OpenAIChatOptions(background=True), + ) + deadline = asyncio.get_running_loop().time() + 180 + while response.continuation_token is not None: + if asyncio.get_running_loop().time() >= deadline: + pytest.fail("Background local-tool probe did not finish within 180 seconds.") + await asyncio.sleep(1) + response = await agent.run( + session=session, + options=OpenAIChatOptions( + continuation_token=cast(OpenAIContinuationToken, response.continuation_token), + ), + ) + + assert executions == [1] + assert response.text == "probe-result-step-1" + + @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_integration_tests_disabled diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 636fd38893c..6ead6a55895 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1731,8 +1731,11 @@ def _prepare_messages_for_openai( """ reasoning_items: dict[str, dict[str, Any]] = {} if not request_uses_service_side_storage: - self._validate_reasoning_groups_for_stateless_replay(chat_messages) reasoning_items = self._prepare_reasoning_items_for_openai(chat_messages) + self._validate_reasoning_groups_for_stateless_replay( + chat_messages, + replayable_reasoning_ids=set(reasoning_items), + ) serialized_reasoning_ids: set[str] = set() list_of_list = [ @@ -1750,7 +1753,12 @@ def _prepare_messages_for_openai( # items (drop unmatched). See `_AF_MCP_PENDING_OUTPUT_KEY`. return self._coalesce_pending_mcp_results(flat) - def _validate_reasoning_groups_for_stateless_replay(self, chat_messages: Sequence[Message]) -> None: + def _validate_reasoning_groups_for_stateless_replay( + self, + chat_messages: Sequence[Message], + *, + replayable_reasoning_ids: set[str], + ) -> None: """Reject reasoning-bound tool groups that cannot be reconstructed.""" group_reasoning_contents: dict[str, list[Content]] = {} group_call_ids: dict[str, list[str]] = {} @@ -1802,21 +1810,27 @@ def _validate_reasoning_groups_for_stateless_replay(self, chat_messages: Sequenc group_call_ids.setdefault(group_id, []).extend(call_ids) invalid_groups: list[str] = [] + reasoning_id_groups: dict[str, set[str]] = {} + for group_id, reasoning_contents in group_reasoning_contents.items(): + for content in reasoning_contents: + if content.id: + reasoning_id_groups.setdefault(content.id, set()).add(group_id) + reasoning_ids_reused_across_groups = { + reasoning_id for reasoning_id, group_ids in reasoning_id_groups.items() if len(group_ids) > 1 + } + for group_id in groups_with_reasoning: call_ids = list(dict.fromkeys(group_call_ids.get(group_id, []))) if not call_ids: continue reasoning_contents = group_reasoning_contents.get(group_id, []) - replayable_reasoning_ids = { - content.id - for content in reasoning_contents - if content.id and (content.protected_data or content.additional_properties.get("encrypted_content")) - } missing_reasoning_ids = list( dict.fromkeys( content.id or "" for content in reasoning_contents - if not content.id or content.id not in replayable_reasoning_ids + if not content.id + or content.id not in replayable_reasoning_ids + or content.id in reasoning_ids_reused_across_groups ) ) if not reasoning_contents: @@ -1828,9 +1842,9 @@ def _validate_reasoning_groups_for_stateless_replay(self, chat_messages: Sequenc if invalid_groups: raise ChatClientInvalidRequestException( - f"Stateless replay cannot reconstruct {'; '.join(invalid_groups)} because encrypted reasoning " - "content is missing. Use service-side continuation or explicitly configured atomic compaction to " - "exclude each complete reasoning/tool-call group." + f"Stateless replay cannot reconstruct {'; '.join(invalid_groups)} because required reasoning replay " + "data is missing or invalid. Use service-side continuation or explicitly configured atomic " + "compaction to exclude each complete reasoning/tool-call group." ) def _prepare_message_for_openai( @@ -1989,6 +2003,8 @@ def _prepare_reasoning_items_for_openai( None, ) if not encrypted_content: + if provider_item := self._prepare_provider_reasoning_item_for_openai(reasoning_id, contents): + reasoning_items[reasoning_id] = provider_item continue item: dict[str, Any] = { @@ -2013,6 +2029,14 @@ def _prepare_reasoning_items_for_openai( reasoning_items[reasoning_id] = item return reasoning_items + def _prepare_provider_reasoning_item_for_openai( + self, + reasoning_id: str, + contents: Sequence[Content], + ) -> dict[str, Any] | None: + """Return a provider-native reasoning item for stateless replay, when supported.""" + return None + def _prepare_content_for_openai( self, role: Role | str, diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 92b68650dac..ea0cdb49113 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1899,11 +1899,59 @@ async def test_stateless_reasoning_group_without_encrypted_content_is_rejected_b assert "rs_missing" in message assert "call_one" in message assert "call_two" in message + assert "required reasoning replay data is missing or invalid" in message assert "service-side continuation" in message assert "atomic compaction" in message create.assert_not_awaited() +async def test_stateless_reasoning_id_reused_across_groups_is_rejected_before_transport() -> None: + client = OpenAIChatClient(model="test-model", api_key="test-key") + create = AsyncMock() + messages = [ + Message( + role="assistant", + contents=[ + Content.from_text_reasoning(id="rs_reused", text="First tool group."), + Content.from_function_call(call_id="call_first", name="first_tool", arguments="{}"), + ], + ), + Message( + role="tool", + contents=[Content.from_function_result(call_id="call_first", result="first result")], + ), + Message(role="user", contents=["Start a separate tool group."]), + Message( + role="assistant", + contents=[ + Content.from_text_reasoning( + id="rs_reused", + text="Second tool group.", + protected_data="encrypted-second-group", + ), + Content.from_function_call(call_id="call_second", name="second_tool", arguments="{}"), + ], + ), + Message( + role="tool", + contents=[Content.from_function_result(call_id="call_second", result="second result")], + ), + ] + + with ( + patch.object(client.client.responses.with_raw_response, "create", new=create), + pytest.raises(ChatClientInvalidRequestException) as exc_info, + ): + await client.get_response(messages, options={"store": False}) + + message = str(exc_info.value) + assert "rs_reused" in message + assert "call_first" in message + assert "call_second" in message + assert "required reasoning replay data is missing or invalid" in message + create.assert_not_awaited() + + async def test_partially_compacted_reasoning_group_is_rejected_before_transport() -> None: async def exclude_reasoning_message(messages: list[Message]) -> bool: messages[0].additional_properties["_excluded"] = True From b20927b921d4f571e066dc8af537a4530604b772 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 24 Sep 2026 09:15:23 +0000 Subject: [PATCH 40/42] Python: fix(python): validate settings and expose agent lifecycle methods (#8709) * fix(python): validate settings and expose agent lifecycle methods * fix(python): preserve settings coercion compatibility --- python/packages/core/AGENTS.md | 7 +- .../packages/core/agent_framework/_agents.py | 72 ++++--- .../core/agent_framework/_settings.py | 60 ++++-- .../packages/core/tests/core/test_agents.py | 120 ++++++++++++ .../packages/core/tests/core/test_settings.py | 176 +++++++++++++++++- 5 files changed, 389 insertions(+), 46 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 61cd052f99b..e884d74b6f1 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -45,13 +45,18 @@ agent_framework/ display a mask. String-only APIs such as `str.join()` and JSON encoding reject it unless callers explicitly convert it. Use `get_secret_value()` when passing credentials to provider SDKs; `str(secret)` returns the mask. - **`load_settings`** accepts plain string overrides for `SecretString` fields and wraps them, as it does for - environment and `.env` values. Existing `SecretString` overrides are preserved. + environment and `.env` values. Existing `SecretString` overrides are preserved. Invalid supplied numeric and + boolean environment or `.env` values raise `ValueError` identifying the field and source instead of falling back + to raw strings. ### Agents (`_agents.py`) - **`SupportsAgentRun`** - Protocol defining the agent interface - **`BaseAgent`** - Abstract base class for agents - **`Agent`** - Main agent class wrapping a chat client with tools, instructions, and middleware +- **`RawAgent.open()` / `close()`** (inherited by `Agent`) - Explicitly enter the client's and configured MCP tools' + async contexts, then release them along with lazily connected MCP tools. `async with agent` delegates to these + methods; a partially failed `open()` closes resources already entered. ### Chat Clients (`_clients.py`) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 379601fa256..11e317dcb80 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -892,8 +892,7 @@ def get_weather(location: str) -> str: name="weather-agent", instructions="You are a weather assistant.", tools=get_weather, - temperature=0.7, - max_tokens=500, + default_options={"temperature": 0.7, "max_tokens": 500}, ) # Use streaming responses @@ -912,20 +911,30 @@ def get_weather(location: str) -> str: client = OpenAIChatClient(model="gpt-4o") agent: Agent[OpenAIChatOptions] = Agent( client=client, - name="reasoning-agent", - instructions="You are a reasoning assistant.", - options={ + name="typed-agent", + instructions="You are a helpful assistant.", + default_options={ "temperature": 0.7, "max_tokens": 500, - "reasoning_effort": "high", # OpenAI-specific, IDE will autocomplete! + "include": ["message.output_text.logprobs"], # OpenAI-specific option }, ) - # Or pass options at runtime + # Or override default options at runtime response = await agent.run( "What is 25 * 47?", - options={"temperature": 0.0, "logprobs": True}, + options={"temperature": 0.0}, ) + + Explicit resource management (equivalent to ``async with agent``): + + .. code-block:: python + + await agent.open() + try: + response = await agent.run("Hello") + finally: + await agent.close() """ AGENT_PROVIDER_NAME: ClassVar[str] = "microsoft.agent_framework" @@ -1049,23 +1058,29 @@ def __init__( self._async_exit_stack = AsyncExitStack() self._update_agent_name_and_description() - async def __aenter__(self) -> Self: - """Enter the async context manager. + async def open(self) -> Self: + """Open the client's and configured MCP tools' async contexts. - If any of the client or local_mcp_tools are context managers, - they will be entered into the async exit stack to ensure proper cleanup. - - Note: - This list might be extended in the future. + Call ``await agent.close()`` when finished, or use ``async with agent`` + to open and close automatically. Already-entered contexts are closed if + a later context fails to open. Returns: - The Agent instance. + The agent instance. """ - for context_manager in chain([self.client], self.mcp_tools): - if isinstance(context_manager, AbstractAsyncContextManager): - await self._async_exit_stack.enter_async_context(context_manager) + try: + for context_manager in chain([self.client], self.mcp_tools): + if isinstance(context_manager, AbstractAsyncContextManager): + await self._async_exit_stack.enter_async_context(context_manager) + except BaseException: + await self.close() + raise return self + async def __aenter__(self) -> Self: + """Enter the async context manager by opening managed resources.""" + return await self.open() + def _get_history_providers(self) -> list[HistoryProvider]: return [provider for provider in self.context_providers if isinstance(provider, HistoryProvider)] @@ -1095,22 +1110,18 @@ def _resolve_per_service_call_history_providers( ) return history_providers + async def close(self) -> None: + """Close managed client and MCP contexts, including lazily opened MCP tools.""" + await self._async_exit_stack.aclose() + async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any, ) -> None: - """Exit the async context manager. - - Close the async exit stack to ensure all context managers are exited properly. - - Args: - exc_type: The exception type if an exception was raised, None otherwise. - exc_val: The exception value if an exception was raised, None otherwise. - exc_tb: The exception traceback if an exception was raised, None otherwise. - """ - await self._async_exit_stack.aclose() + """Exit the async context manager by closing managed resources.""" + await self.close() def _update_agent_name_and_description(self) -> None: """Update the agent name in the chat client. @@ -1944,6 +1955,9 @@ class Agent( - OpenTelemetry-based telemetry for observability For a minimal implementation without these features, use :class:`RawAgent`. + + Use ``await agent.open()`` and ``await agent.close()`` for explicit resource + management, or ``async with agent`` to close managed resources automatically. """ @overload diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py index 1629eb5ff96..d674c47f99e 100644 --- a/python/packages/core/agent_framework/_settings.py +++ b/python/packages/core/agent_framework/_settings.py @@ -34,7 +34,7 @@ class MySettings(TypedDict, total=False): import sys from collections.abc import Callable, Sequence from contextlib import suppress -from typing import Any, Union, get_args, get_origin, get_type_hints +from typing import Any, Literal, Union, cast, get_args, get_origin, get_type_hints from dotenv import dotenv_values @@ -156,9 +156,25 @@ def _coerce_value(value: str, target_type: type) -> Any: if args and type(None) in args: for arg in args: if arg is not type(None): + if arg is Any: + return value + runtime_type = _runtime_class(arg) + if runtime_type is None and get_origin(arg) is not Literal: + continue with suppress(ValueError, TypeError): - return _coerce_value(value, arg) - return value + coerced = _coerce_value(value, arg) + if runtime_type is None or isinstance(coerced, runtime_type): + return coerced + raise ValueError("Value cannot be converted to any allowed type.") + + if origin is Literal: + for choice in args: + choice_type = cast(type[Any], type(choice)) + with suppress(ValueError, TypeError): + coerced = _coerce_value(value, choice_type) + if type(coerced) is choice_type and coerced == choice: + return coerced + raise ValueError("Value is not an allowed literal.") # Handle SecretString if target_type is SecretString or (isinstance(target_type, type) and issubclass(target_type, SecretString)): @@ -172,11 +188,25 @@ def _coerce_value(value: str, target_type: type) -> Any: if target_type is float: return float(value) if target_type is bool: - return value.lower() in ("true", "1", "yes", "on") + normalized = value.strip().lower() + if normalized in ("true", "1", "yes", "on"): + return True + if normalized in ("false", "0", "no", "off"): + return False + raise ValueError("Value is not a recognized boolean.") return value +def _coerce_from_source(value: str, target_type: type, *, field_name: str, source: str) -> Any: + """Coerce a supplied value or identify the setting and source on failure.""" + try: + return _coerce_value(value, target_type) + except (ValueError, TypeError): + pass + raise ValueError(f"Invalid value for setting '{field_name}' from {source}.") + + def _runtime_class(annotation: Any) -> type | None: """Return the class ``isinstance`` can test *annotation* against, or ``None``. @@ -185,6 +215,8 @@ def _runtime_class(annotation: Any) -> type | None: is always preferred. Annotations without a runtime class, such as ``Literal[...]``, return ``None`` so callers can skip validation instead of guessing. """ + if annotation is Any: + return None origin = get_origin(annotation) candidate = annotation if origin is None else origin return candidate if isinstance(candidate, type) else None @@ -245,7 +277,8 @@ def _check_override_type(value: Any, field_type: type, field_name: str) -> None: allowed_names = ", ".join(t.__name__ for t in allowed) raise ValueError( - f"Invalid type for setting '{field_name}': expected {allowed_names}, got {type(value).__name__}." + f"Invalid type for setting '{field_name}' from explicit override: " + f"expected {allowed_names}, got {type(value).__name__}." ) @@ -293,7 +326,8 @@ def load_settings( FileNotFoundError: If *env_file_path* was provided but the file does not exist. SettingNotFoundError: If a required field could not be resolved from any source, or if a mutually exclusive constraint is violated. - ValueError: If an override value has an incompatible type. + ValueError: If an override value has an incompatible type or an environment + or ``.env`` value cannot be coerced to the setting's type. """ encoding = env_file_encoding or "utf-8" @@ -332,19 +366,17 @@ def load_settings( if loaded_dotenv_values: dotenv_value = loaded_dotenv_values.get(env_var_name) if dotenv_value is not None: - try: - result[field_name] = _coerce_value(dotenv_value, field_type) - except (ValueError, TypeError): - result[field_name] = dotenv_value + result[field_name] = _coerce_from_source( + dotenv_value, field_type, field_name=field_name, source=f".env file '{env_file_path}'" + ) continue # 3. Environment variable env_value = os.getenv(env_var_name) if env_value is not None: - try: - result[field_name] = _coerce_value(env_value, field_type) - except (ValueError, TypeError): - result[field_name] = env_value + result[field_name] = _coerce_from_source( + env_value, field_type, field_name=field_name, source=f"environment variable '{env_var_name}'" + ) continue # 4. Default from TypedDict class-level defaults, or None for optional fields diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index c3e132656aa..e0e79153288 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -35,6 +35,7 @@ InMemoryStore, Message, MessageInjectionMiddleware, + RawAgent, ResponseStream, ServiceSessionId, SessionContext, @@ -93,6 +94,46 @@ def get_mcp_client(self) -> contextlib.AbstractAsyncContextManager[Any]: # type raise NotImplementedError +class _LifecycleClient(MockBaseChatClient): + def __init__(self, events: list[str]) -> None: + super().__init__() + self.events = events + self.exit_exception_type: type[BaseException] | None = None + + async def __aenter__(self) -> "_LifecycleClient": + self.events.append("client.enter") + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: + self.exit_exception_type = exc_type + self.events.append("client.exit") + + +class _LifecycleMCPTool(_ConnectedMCPTool): + def __init__(self, name: str, events: list[str], *, fail_on_enter: bool = False) -> None: + super().__init__(name=name, function_names=[]) + self.events = events + self.fail_on_enter = fail_on_enter + self.exit_exception_type: type[BaseException] | None = None + + async def __aenter__(self) -> "_LifecycleMCPTool": + self.events.append(f"{self.name}.enter") + if self.fail_on_enter: + raise RuntimeError("MCP entry failed") + self.is_connected = True + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: Any, + ) -> None: + self.exit_exception_type = exc_type + self.events.append(f"{self.name}.exit") + self.is_connected = False + + class _RecordingHistoryProvider(HistoryProvider): def __init__(self, source_id: str = "recording_history") -> None: super().__init__(source_id=source_id) @@ -162,6 +203,85 @@ def test_agent_type(agent: SupportsAgentRun) -> None: assert isinstance(agent, SupportsAgentRun) +@pytest.mark.parametrize("agent_type", [Agent, RawAgent]) +async def test_agent_explicit_open_close(agent_type: type[RawAgent]) -> None: + events: list[str] = [] + client = _LifecycleClient(events) + mcp_tool = _LifecycleMCPTool("mcp", events) + agent = agent_type(client=client, tools=[mcp_tool]) + + assert await agent.open() is agent + assert events == ["client.enter", "mcp.enter"] + + await agent.close() + await agent.close() + + assert events == ["client.enter", "mcp.enter", "mcp.exit", "client.exit"] + + +@pytest.mark.parametrize("agent_type", [Agent, RawAgent]) +async def test_agent_async_with_delegates_to_open_close(agent_type: type[RawAgent]) -> None: + events: list[str] = [] + client = _LifecycleClient(events) + mcp_tool = _LifecycleMCPTool("mcp", events) + agent = agent_type(client=client, tools=[mcp_tool]) + + with ( + patch.object(agent, "open", wraps=agent.open) as open_mock, + patch.object(agent, "close", wraps=agent.close) as close_mock, + pytest.raises(RuntimeError, match="body failed"), + ): + async with agent as entered_agent: + assert entered_agent is agent + assert events == ["client.enter", "mcp.enter"] + raise RuntimeError("body failed") + + open_mock.assert_awaited_once_with() + close_mock.assert_awaited_once_with() + assert events == ["client.enter", "mcp.enter", "mcp.exit", "client.exit"] + assert client.exit_exception_type is None + assert mcp_tool.exit_exception_type is None + + +@pytest.mark.parametrize("agent_type", [Agent, RawAgent]) +async def test_agent_failed_open_closes_entered_contexts(agent_type: type[RawAgent]) -> None: + events: list[str] = [] + client = _LifecycleClient(events) + first_tool = _LifecycleMCPTool("first", events) + failing_tool = _LifecycleMCPTool("failing", events, fail_on_enter=True) + agent = agent_type(client=client, tools=[first_tool, failing_tool]) + + with pytest.raises(RuntimeError, match="MCP entry failed"): + await agent.open() + + assert events == ["client.enter", "first.enter", "failing.enter", "first.exit", "client.exit"] + assert first_tool.exit_exception_type is None + assert client.exit_exception_type is None + + events.clear() + failing_tool.fail_on_enter = False + assert await agent.open() is agent + await agent.close() + assert events == ["client.enter", "first.enter", "failing.enter", "failing.exit", "first.exit", "client.exit"] + + +async def test_agent_close_cleans_up_mcp_tool_opened_during_run(chat_client_base: MockBaseChatClient) -> None: + events: list[str] = [] + mcp_tool = _LifecycleMCPTool("lazy", events) + mcp_tool.is_connected = False + agent = Agent(client=chat_client_base, tools=[mcp_tool]) + + await agent.run("hello") + + assert events == ["lazy.enter"] + assert mcp_tool.is_connected + + await agent.close() + + assert events == ["lazy.enter", "lazy.exit"] + assert not mcp_tool.is_connected + + async def test_agent_run(agent: SupportsAgentRun) -> None: response = await agent.run("test") assert response.messages[0].role == "assistant" diff --git a/python/packages/core/tests/core/test_settings.py b/python/packages/core/tests/core/test_settings.py index 9070c31a6ed..af480ffe833 100644 --- a/python/packages/core/tests/core/test_settings.py +++ b/python/packages/core/tests/core/test_settings.py @@ -147,6 +147,48 @@ def test_overrides_beat_dotenv_and_env_vars(self, monkeypatch: pytest.MonkeyPatc finally: os.unlink(env_path) + def test_override_ignores_invalid_lower_priority_values( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + env_file = tmp_path / ".env" + env_file.write_text("TEST_APP_TIMEOUT=invalid\n", encoding="utf-8") + monkeypatch.setenv("TEST_APP_TIMEOUT", "also-invalid") + + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=str(env_file), timeout=60) + + assert settings["timeout"] == 60 + + @pytest.mark.parametrize( + ("field", "invalid", "environment_value"), + [ + ("timeout", "not-an-int", "30"), + ("rate_limit", "not-a-float", "2.5"), + ("enabled", "maybe", "true"), + ], + ) + def test_invalid_dotenv_value_raises_without_falling_back_to_environment( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, field: str, invalid: str, environment_value: str + ) -> None: + env_var = f"TEST_APP_{field.upper()}" + env_file = tmp_path / ".env" + env_file.write_text(f"{env_var}={invalid}\n", encoding="utf-8") + monkeypatch.setenv(env_var, environment_value) + + with pytest.raises(ValueError, match=rf"setting '{field}' from \.env file") as exc_info: + load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=str(env_file)) + + assert str(env_file) in str(exc_info.value) + assert invalid not in str(exc_info.value) + + def test_bool_and_float_from_dotenv(self, tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("TEST_APP_ENABLED=ON\nTEST_APP_RATE_LIMIT=2.5\n", encoding="utf-8") + + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=str(env_file)) + + assert settings["enabled"] is True + assert settings["rate_limit"] == 2.5 + def test_missing_dotenv_file_raises(self) -> None: with pytest.raises(FileNotFoundError): load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path="/nonexistent/.env") @@ -365,6 +407,126 @@ def test_bool_coercion_false_values(self, monkeypatch: pytest.MonkeyPatch) -> No settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") assert settings["enabled"] is False, f"Failed for {false_val}" + def test_bool_coercion_ignores_surrounding_whitespace(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_ENABLED", " YeS ") + + assert load_settings(SimpleSettings, env_prefix="TEST_APP_")["enabled"] is True + + @pytest.mark.parametrize( + ("field", "invalid"), + [("timeout", "sk-invalid-number"), ("rate_limit", "not-a-float")], + ) + def test_invalid_optional_numeric_from_environment_raises( + self, monkeypatch: pytest.MonkeyPatch, field: str, invalid: str + ) -> None: + env_var = f"TEST_APP_{field.upper()}" + monkeypatch.setenv(env_var, invalid) + + with pytest.raises(ValueError, match=f"setting '{field}' from environment variable '{env_var}'") as exc_info: + load_settings(SimpleSettings, env_prefix="TEST_APP_") + + assert invalid not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + @pytest.mark.parametrize("invalid", ["maybe", "2", ""]) + def test_invalid_bool_from_environment_raises(self, monkeypatch: pytest.MonkeyPatch, invalid: str) -> None: + monkeypatch.setenv("TEST_APP_ENABLED", invalid) + + with pytest.raises(ValueError, match="setting 'enabled' from environment variable 'TEST_APP_ENABLED'"): + load_settings(SimpleSettings, env_prefix="TEST_APP_") + + def test_unsupported_union_arm_does_not_accept_invalid_numeric_string( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + class MixedSettings(TypedDict, total=False): + value: int | dict[str, Any] | None + + monkeypatch.setenv("MIXED_VALUE", "invalid") + + with pytest.raises(ValueError, match="setting 'value' from environment variable 'MIXED_VALUE'"): + load_settings(MixedSettings, env_prefix="MIXED_") + + def test_string_arm_preserves_provider_specific_parsing(self, monkeypatch: pytest.MonkeyPatch) -> None: + class MixedSettings(TypedDict, total=False): + telemetry: dict[str, Any] | str | None + + monkeypatch.setenv("MIXED_TELEMETRY", '{"enabled": true}') + + settings = load_settings(MixedSettings, env_prefix="MIXED_") + + assert settings["telemetry"] == '{"enabled": true}' + + def test_optional_any_preserves_environment_value(self, monkeypatch: pytest.MonkeyPatch) -> None: + class AnySettings(TypedDict, total=False): + value: Any | None + + monkeypatch.setenv("TEST_VALUE", '{"enabled": true}') + + settings = load_settings(AnySettings, env_prefix="TEST_") + + assert settings["value"] == '{"enabled": true}' + + def test_optional_any_preserves_dotenv_value(self, tmp_path: Path) -> None: + class AnySettings(TypedDict, total=False): + value: Any | None + + env_file = tmp_path / ".env" + env_file.write_text("TEST_VALUE=provider-specific-value\n", encoding="utf-8") + + settings = load_settings(AnySettings, env_prefix="TEST_", env_file_path=str(env_file)) + + assert settings["value"] == "provider-specific-value" + + def test_literal_union_accepts_valid_and_rejects_invalid_environment_values( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + class LiteralSettings(TypedDict, total=False): + mode: Literal["fast", "safe"] | None + + monkeypatch.setenv("TEST_MODE", "fast") + assert load_settings(LiteralSettings, env_prefix="TEST_")["mode"] == "fast" + + monkeypatch.setenv("TEST_MODE", "unknown") + with pytest.raises(ValueError, match="setting 'mode' from environment variable 'TEST_MODE'"): + load_settings(LiteralSettings, env_prefix="TEST_") + + def test_non_string_literal_union_coerces_environment_values(self, monkeypatch: pytest.MonkeyPatch) -> None: + class LiteralSettings(TypedDict, total=False): + attempts: Literal[1, 2] | None + enabled: Literal[True, False] | None + + monkeypatch.setenv("TEST_ATTEMPTS", "2") + monkeypatch.setenv("TEST_ENABLED", "false") + + settings = load_settings(LiteralSettings, env_prefix="TEST_") + + assert settings["attempts"] == 2 + assert type(settings["attempts"]) is int + assert settings["enabled"] is False + assert type(settings["enabled"]) is bool + + def test_non_string_literal_union_coerces_dotenv_values(self, tmp_path: Path) -> None: + class LiteralSettings(TypedDict, total=False): + attempts: Literal[3, 4] | None + + env_file = tmp_path / ".env" + env_file.write_text("TEST_ATTEMPTS=4\n", encoding="utf-8") + + settings = load_settings(LiteralSettings, env_prefix="TEST_", env_file_path=str(env_file)) + + assert settings["attempts"] == 4 + assert type(settings["attempts"]) is int + + def test_non_string_literal_uses_strict_type_equality(self, monkeypatch: pytest.MonkeyPatch) -> None: + class LiteralSettings(TypedDict, total=False): + value: Literal[1] | None + + monkeypatch.setenv("TEST_VALUE", "true") + + with pytest.raises(ValueError, match="setting 'value' from environment variable 'TEST_VALUE'"): + load_settings(LiteralSettings, env_prefix="TEST_") + class TestRequiredFields: """Test required field validation.""" @@ -403,8 +565,7 @@ class TestOverrideTypeValidation: """Test override type validation.""" def test_invalid_type_raises(self) -> None: - - with pytest.raises(ValueError, match="Invalid type for setting 'api_key'"): + with pytest.raises(ValueError, match="Invalid type for setting 'api_key' from explicit override"): load_settings(SimpleSettings, env_prefix="TEST_", api_key={"bad": "type"}) def test_valid_types_accepted(self) -> None: @@ -475,6 +636,17 @@ class LiteralUnionSettings(TypedDict, total=False): assert settings["mode"] == ["a", "b"] + def test_callable_override_is_preserved(self) -> None: + class CallableSettings(TypedDict, total=False): + token_provider: Callable[[], str] | None + + def token_provider() -> str: + return "token" + + settings = load_settings(CallableSettings, token_provider=token_provider) + + assert settings["token_provider"] is token_provider + class TestMutuallyExclusive: """Test mutually exclusive field validation via tuple entries in required_fields.""" From 2191c60c5e7de2614c84f790015ed0355aabcd47 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 24 Sep 2026 09:36:17 +0000 Subject: [PATCH 41/42] Python: Preserve compaction through middleware rewrites (#8671) * Python: preserve compaction through middleware rewrites * Python: harden replacement summary reconciliation * Python: snapshot fallback reconciliation messages --- python/packages/core/AGENTS.md | 2 +- .../core/agent_framework/_compaction.py | 234 ++++++++++- .../core/agent_framework/_middleware.py | 84 +++- .../packages/core/tests/core/test_clients.py | 391 ++++++++++++++++++ .../core/tests/core/test_middleware.py | 22 +- 5 files changed, 709 insertions(+), 24 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index e884d74b6f1..18540ee21fd 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -146,7 +146,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`AgentMiddleware`** - Intercepts agent `run()` calls - **`ChatMiddleware`** - Intercepts chat client `get_response()` calls - **`FunctionMiddleware`** - Intercepts function/tool invocations -- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration). +- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration). Chat middleware that constructs provider-local replacement messages must call `context.record_message_replacement(...)` for every replacement before downstream compaction so complete summaries can reconcile to caller-owned messages without persisting the replacements themselves. - **`MessageInjectionMiddleware`** - Session-scoped chat middleware that lets tools or other code enqueue messages for the next model call in the current `AgentSession`; it drains queued messages into the next call and loops only when no function calls need to be handled by the function invocation layer. ### Sessions (`_sessions.py`) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index 45205866561..c491fd884a4 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -423,9 +423,94 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] source_messages: list[Message], working_messages: Sequence[Message], source_message_identities: set[int], + *, + message_replacements: Sequence[tuple[Message, Sequence[Message]]] = (), + warn_on_rejection: bool = False, ) -> None: - """Reconcile summaries supported by source-owned messages without persisting unrelated rewrites.""" + """Reconcile summaries rooted in source-owned messages without persisting unrelated rewrites.""" + source_messages_by_identity = {id(message): message for message in source_messages} + source_messages_by_id: dict[str, list[Message]] = {} + for source_message in source_messages: + if source_message.message_id: + source_messages_by_id.setdefault(source_message.message_id, []).append(source_message) source_message_ids = {message.message_id for message in source_messages if message.message_id} + + replacement_messages_by_identity: dict[int, Message] = {} + replacement_dependencies: dict[int, set[int]] = {} + for replacement, sources in message_replacements: + replacement_identity = id(replacement) + replacement_messages_by_identity[replacement_identity] = replacement + replacement_dependencies.setdefault(replacement_identity, set()).update(id(source) for source in sources) + + roots_by_identity = { + source_identity: {source_identity} + for source_identity in source_message_identities + if source_identity in source_messages_by_identity + } + pending_replacement_identities = set(replacement_dependencies).difference(roots_by_identity) + while pending_replacement_identities: + newly_supported_replacements = { + replacement_identity + for replacement_identity in pending_replacement_identities + if replacement_dependencies[replacement_identity] + and replacement_dependencies[replacement_identity].issubset(roots_by_identity) + } + if not newly_supported_replacements: + break + for replacement_identity in newly_supported_replacements: + roots_by_identity[replacement_identity] = set().union( + *( + roots_by_identity[source_identity] + for source_identity in replacement_dependencies[replacement_identity] + ) + ) + pending_replacement_identities.difference_update(newly_supported_replacements) + + resolved_replacement_identities = set(replacement_messages_by_identity).intersection(roots_by_identity) + superseded_replacement_identities = { + source_identity + for replacement_identity in resolved_replacement_identities + for source_identity in replacement_dependencies[replacement_identity] + if source_identity in resolved_replacement_identities + } + leaf_replacement_identities = resolved_replacement_identities.difference(superseded_replacement_identities) + required_leaves_by_source_identity: dict[int, set[int]] = {} + for replacement_identity in leaf_replacement_identities: + for source_identity in roots_by_identity[replacement_identity]: + required_leaves_by_source_identity.setdefault(source_identity, set()).add(replacement_identity) + + roots_by_supported_id: dict[str, set[int]] = {} + leaves_by_supported_id: dict[str, set[int]] = {} + ambiguous_supported_ids: set[str] = set() + + def add_supported_id(message_id: str, roots: set[int], leaves: set[int]) -> None: + if message_id in ambiguous_supported_ids: + return + existing_roots = roots_by_supported_id.get(message_id) + existing_leaves = leaves_by_supported_id.get(message_id) + if existing_roots is not None and (existing_roots != roots or existing_leaves != leaves): + roots_by_supported_id.pop(message_id, None) + leaves_by_supported_id.pop(message_id, None) + ambiguous_supported_ids.add(message_id) + return + roots_by_supported_id[message_id] = roots + leaves_by_supported_id[message_id] = leaves + + for source_message in source_messages: + if source_message.message_id: + add_supported_id(source_message.message_id, {id(source_message)}, set()) + + resolved_replacements_by_id: dict[str, list[tuple[Message, set[int]]]] = {} + for replacement_identity, replacement in replacement_messages_by_identity.items(): + roots = roots_by_identity.get(replacement_identity) + if roots is None or not replacement.message_id: + continue + replacement_leaves: set[int] = ( + {replacement_identity} if replacement_identity in leaf_replacement_identities else set() + ) + add_supported_id(replacement.message_id, roots, replacement_leaves) + resolved_replacements_by_id.setdefault(replacement.message_id, []).append((replacement, roots)) + candidates: list[tuple[Message, set[str]]] = [] for message in working_messages: if id(message) in source_message_identities: @@ -443,17 +528,125 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] ): candidates.append((message, set(cast("list[str]", summarized_message_ids)))) - dependencies = {message.message_id: summary_ids for message, summary_ids in candidates if message.message_id} - supported_ids = set(source_message_ids) - pending_ids = set(dependencies) - while pending_ids: - newly_supported = {summary_id for summary_id in pending_ids if dependencies[summary_id].issubset(supported_ids)} - if not newly_supported: + dependencies: dict[str, set[str]] = {} + ambiguous_summary_ids: set[str] = set() + for message, summary_ids in candidates: + if not message.message_id: + continue + if message.message_id in dependencies: + ambiguous_summary_ids.add(message.message_id) + continue + dependencies[message.message_id] = summary_ids + + resolved_summary_roots: dict[str, set[int]] = {} + resolved_summary_leaves: dict[str, set[int]] = {} + pending_summary_ids = set(dependencies).difference(ambiguous_summary_ids) + while pending_summary_ids: + newly_supported_summaries: dict[str, tuple[set[int], set[int]]] = {} + for summary_id in pending_summary_ids: + dependency_roots: list[set[int]] = [] + dependency_leaves: list[set[int]] = [] + for dependency_id in dependencies[summary_id]: + if dependency_id in ambiguous_supported_ids: + break + roots: set[int] | None = resolved_summary_roots.get(dependency_id) + dependency_leaf_ids: set[int] | None = resolved_summary_leaves.get(dependency_id) + if roots is None: + roots = roots_by_supported_id.get(dependency_id) + dependency_leaf_ids = leaves_by_supported_id.get(dependency_id) + if roots is None: + break + dependency_roots.append(roots) + dependency_leaves.append(dependency_leaf_ids or set()) + else: + summary_roots: set[int] = set() + for roots in dependency_roots: + summary_roots.update(roots) + summary_leaves: set[int] = set() + for leaves in dependency_leaves: + summary_leaves.update(leaves) + existing_roots = roots_by_supported_id.get(summary_id) + existing_leaves = leaves_by_supported_id.get(summary_id) + if summary_id not in ambiguous_supported_ids and ( + existing_roots is None or (existing_roots == summary_roots and existing_leaves == summary_leaves) + ): + newly_supported_summaries[summary_id] = (summary_roots, summary_leaves) + + if not newly_supported_summaries: break - supported_ids.update(newly_supported) - pending_ids.difference_update(newly_supported) + for summary_id, (summary_roots, summary_leaves) in newly_supported_summaries.items(): + resolved_summary_roots[summary_id] = summary_roots + resolved_summary_leaves[summary_id] = summary_leaves + roots_by_supported_id[summary_id] = summary_roots + leaves_by_supported_id[summary_id] = summary_leaves + pending_summary_ids.difference_update(newly_supported_summaries) + + accepted_ids = { + summary_id + for summary_id, summary_roots in resolved_summary_roots.items() + if all( + required_leaves_by_source_identity.get(source_identity, set()).issubset(resolved_summary_leaves[summary_id]) + for source_identity in summary_roots + ) + } + + def nested_summary_ids(summary_id: str) -> set[str]: + nested_ids: set[str] = set() + pending_ids = [summary_id] + while pending_ids: + current_id = pending_ids.pop() + for dependency_id in dependencies[current_id]: + if dependency_id not in resolved_summary_roots or dependency_id in nested_ids: + continue + nested_ids.add(dependency_id) + pending_ids.append(dependency_id) + return nested_ids + + consumed_accepted_ids: set[str] = set() + used_summary_ids = set(accepted_ids) + for summary_id in accepted_ids: + nested_ids = nested_summary_ids(summary_id) + used_summary_ids.update(nested_ids) + consumed_accepted_ids.update(nested_ids.intersection(accepted_ids)) + endpoint_summary_ids = accepted_ids.difference(consumed_accepted_ids) + + def transfer_summary_exclusions(summary_id: str, durable_summary_id: str, visited_ids: set[str]) -> None: + if summary_id in visited_ids: + return + visited_ids.add(summary_id) + for dependency_id in dependencies[summary_id]: + if dependency_id in resolved_summary_roots: + transfer_summary_exclusions(dependency_id, durable_summary_id, visited_ids) + + for source_message in source_messages_by_id.get(dependency_id, ()): + source_annotation = _read_group_annotation_raw(source_message) + if ( + source_annotation is None + or source_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) != summary_id + or source_message.additional_properties.get(EXCLUDED_KEY) is not True + ): + continue + _set_group_summarized_by_summary_id(source_message, durable_summary_id) + + for replacement, roots in resolved_replacements_by_id.get(dependency_id, ()): + replacement_annotation = _read_group_annotation_raw(replacement) + if ( + replacement_annotation is None + or replacement_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) != summary_id + or replacement.additional_properties.get(EXCLUDED_KEY) is not True + ): + continue + exclusion_reason = replacement.additional_properties.get(EXCLUDE_REASON_KEY) + for source_identity in roots: + source_message = source_messages_by_identity[source_identity] + _set_group_summarized_by_summary_id(source_message, durable_summary_id) + source_message.additional_properties[EXCLUDED_KEY] = True + if isinstance(exclusion_reason, str): + source_message.additional_properties[EXCLUDE_REASON_KEY] = exclusion_reason + + for summary_id in endpoint_summary_ids: + transfer_summary_exclusions(summary_id, summary_id, set()) - accepted_ids = set(dependencies).difference(pending_ids) for message in [*source_messages, *(candidate for candidate, _ in candidates)]: annotation = _read_group_annotation_raw(message) if annotation is None: @@ -465,24 +658,25 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] message.additional_properties.pop(EXCLUDED_KEY, None) message.additional_properties.pop(EXCLUDE_REASON_KEY, None) - def source_dependencies(summary_id: str) -> set[str]: - expanded: set[str] = set() - for dependency_id in dependencies[summary_id]: - if dependency_id in source_message_ids: - expanded.add(dependency_id) - elif dependency_id in accepted_ids: - expanded.update(source_dependencies(dependency_id)) - return expanded + if warn_on_rejection: + rejected_count = sum(message.message_id not in used_summary_ids for message, _ in candidates) + if rejected_count: + logger.warning( + "Rejected %d compaction summary message(s) because their dependencies were not fully rooted in " + "caller-owned messages or registered middleware replacements.", + rejected_count, + extra={"compaction_rejected_summary_count": rejected_count}, + ) for message, _ in candidates: if message.message_id not in accepted_ids: continue - summarized_source_ids = source_dependencies(message.message_id) + summarized_source_identities = resolved_summary_roots[message.message_id] insertion_index = min( ( index for index, source_message in enumerate(source_messages) - if source_message.message_id in summarized_source_ids + if id(source_message) in summarized_source_identities ), default=len(source_messages), ) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 45e764f2998..599657baedb 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -575,6 +575,11 @@ class ChatContext: stream_result_hooks: Hooks applied to the finalized response (after finalizer). stream_cleanup_hooks: Hooks executed after stream consumption (before finalizer). + Middleware that constructs provider-local replacement messages can call + :meth:`record_message_replacement` before downstream middleware runs. The replacement + remains call-local, but compaction summaries derived from it can be reconciled back to + the caller-owned source messages. + Examples: .. code-block:: python @@ -646,6 +651,61 @@ def __init__( self.stream_transform_hooks = list(stream_transform_hooks or []) self.stream_result_hooks = list(stream_result_hooks or []) self.stream_cleanup_hooks = list(stream_cleanup_hooks or []) + self._message_replacements: list[tuple[Message, tuple[Message, ...]]] = [] + self._fallback_reconciliation_messages: list[Message] | None = None + + def record_message_replacement( + self, + replacement: Message, + sources: Message | Sequence[Message], + ) -> Message: + """Record replacement provenance for downstream compaction reconciliation. + + This method does not modify :attr:`messages` or persist ``replacement``. It records + that the replacement carries content derived from caller-owned source messages, so + a downstream compaction summary can durably exclude and summarize those sources. + When one source becomes multiple replacement messages, record every replacement; + reconciliation rejects summaries that cover only part of the split. + + Args: + replacement: A middleware-created replacement message. + sources: One or more messages replaced by ``replacement``. + + Returns: + The replacement message, for convenient use while rebuilding a message list. + + Raises: + TypeError: If ``replacement`` or any source is not a :class:`Message`. + ValueError: If no sources are provided or the replacement is also a source. + """ + if not isinstance(replacement, Message): + raise TypeError("replacement must be a Message") + + if isinstance(sources, Message): + source_messages = (sources,) + elif isinstance(sources, Sequence) and not isinstance(sources, (str, bytes, bytearray)): + source_messages = tuple(sources) + else: + raise TypeError("sources must be a Message or a sequence of Message objects") + + if not source_messages: + raise ValueError("sources must contain at least one Message") + + unique_sources: list[Message] = [] + seen_source_identities: set[int] = set() + for source in source_messages: + if not isinstance(source, Message): + raise TypeError("sources must contain only Message objects") + if source is replacement: + raise ValueError("replacement cannot also be one of its sources") + source_identity = id(source) + if source_identity in seen_source_identities: + continue + seen_source_identities.add(source_identity) + unique_sources.append(source) + + self._message_replacements.append((replacement, tuple(unique_sources))) + return replacement class AgentMiddleware(ABC): @@ -1408,7 +1468,13 @@ async def final_wrapper() -> None: async def current_handler() -> None: # MiddlewareTermination bubbles up to execute() to skip post-processing - await self._middleware[index].process(context, create_next_handler(index + 1)) + try: + await self._middleware[index].process(context, create_next_handler(index + 1)) + finally: + if context._fallback_reconciliation_messages is None: # pyright: ignore[reportPrivateUsage] + context._fallback_reconciliation_messages = list( # pyright: ignore[reportPrivateUsage] + context.messages + ) return current_handler @@ -1572,10 +1638,24 @@ def _final_handler( if source_messages is not None: from ._compaction import _reconcile_compaction_summaries # pyright: ignore[reportPrivateUsage] + if downstream_messages is not None: + reconciliation_messages = downstream_messages + else: + fallback_reconciliation_messages = ( + context._fallback_reconciliation_messages # pyright: ignore[reportPrivateUsage] + ) + reconciliation_messages = ( + fallback_reconciliation_messages + if fallback_reconciliation_messages is not None + else middleware_messages + ) + _reconcile_compaction_summaries( source_messages, - downstream_messages if downstream_messages is not None else middleware_messages, + reconciliation_messages, source_message_identities, + message_replacements=context._message_replacements, # pyright: ignore[reportPrivateUsage] + warn_on_rejection=True, ) if stream: diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index bedf0788f11..b122b9c5ebf 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -1,15 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. +import logging from typing import Any from unittest.mock import patch import pytest from agent_framework import ( + EXCLUDE_REASON_KEY, EXCLUDED_KEY, GROUP_ANNOTATION_KEY, GROUP_TOKEN_COUNT_KEY, + SUMMARIZED_BY_SUMMARY_ID_KEY, + SUMMARY_OF_MESSAGE_IDS_KEY, BaseChatClient, ChatMiddleware, ChatResponse, @@ -21,7 +25,9 @@ SupportsChatGetResponse, ToolResultCompactionStrategy, TruncationStrategy, + annotate_message_groups, apply_compaction, + included_messages, tool, ) @@ -44,6 +50,37 @@ async def process(self, context: Any, call_next: Any) -> None: context.messages = original_messages +class _CompactAllButLatestChatMiddleware(ChatMiddleware): + def __init__(self, *, terminate: bool = False) -> None: + self.terminate = terminate + + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + summarized = messages[:-1] + summary = Message( + role="user", + contents=[""], + message_id="summary_1", + additional_properties={ + GROUP_ANNOTATION_KEY: { + SUMMARY_OF_MESSAGE_IDS_KEY: [message.message_id for message in summarized if message.message_id] + } + }, + ) + for message in summarized: + annotation = message.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = summary.message_id + message.additional_properties[EXCLUDED_KEY] = True + message.additional_properties[EXCLUDE_REASON_KEY] = "test_compaction" + messages.insert(0, summary) + context.messages = list(included_messages(messages)) + if self.terminate: + context.result = ChatResponse(messages=[Message(role="assistant", contents=["terminated"])]) + return + await call_next() + + class _FixedSummarizer: async def get_response(self, *args: Any, **kwargs: Any) -> ChatResponse: return ChatResponse(messages=[Message(role="assistant", contents=["SUMMARY"])]) @@ -798,6 +835,360 @@ async def process(self, context: Any, call_next: Any) -> None: assert all(not message.additional_properties.get("_excluded", False) for message in messages) +@pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"]) +@pytest.mark.parametrize("record_replacements", [True, False], ids=["registered", "unregistered"]) +async def test_chat_middleware_reconciles_recorded_message_replacements( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, + stream: bool, + record_replacements: bool, +) -> None: + class _SplitToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role != "tool": + rewritten.append(message) + continue + tool_replacement = Message(role="tool", contents=["tool text"]) + promoted_replacement = Message(role="user", contents=["promoted rich content"]) + if record_replacements: + context.record_message_replacement(tool_replacement, message) + context.record_message_replacement(promoted_replacement, message) + rewritten.extend((tool_replacement, promoted_replacement)) + context.messages = rewritten + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _SplitToolMessage(), + _CompactAllButLatestChatMiddleware(), + ] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + if stream: + stream_response = chat_client_base.get_response(messages, stream=True) + async for _ in stream_response: + pass + else: + await chat_client_base.get_response(messages) + + rejection_records = [ + record for record in caplog.records if record.getMessage().startswith("Rejected 1 compaction summary message") + ] + if not record_replacements: + assert [message.text for message in messages] == ["q1", "tool output", "q2"] + assert all(message.additional_properties.get(EXCLUDED_KEY) is not True for message in messages) + assert len(rejection_records) == 1 + assert rejection_records[0].__dict__["compaction_rejected_summary_count"] == 1 + assert all(message.text not in rejection_records[0].getMessage() for message in messages) + return + + assert [message.text for message in messages] == [ + "", + "q1", + "tool output", + "q2", + ] + assert rejection_records == [] + for source_message in messages[1:3]: + assert source_message.additional_properties[EXCLUDED_KEY] is True + assert source_message.additional_properties[EXCLUDE_REASON_KEY] == "test_compaction" + annotation = source_message.additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_1" + assert messages[3].additional_properties.get(EXCLUDED_KEY) is not True + + +async def test_chat_middleware_reconciles_transitive_message_replacements( + chat_client_base: SupportsChatGetResponse, +) -> None: + class _FirstReplacement(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role == "tool": + rewritten.append( + context.record_message_replacement( + Message(role="tool", contents=["first replacement"]), + message, + ) + ) + else: + rewritten.append(message) + context.messages = rewritten + await call_next() + + class _SecondReplacement(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role == "tool": + rewritten.append( + context.record_message_replacement( + Message(role="tool", contents=["second replacement"]), + message, + ) + ) + else: + rewritten.append(message) + context.messages = rewritten + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _FirstReplacement(), + _SecondReplacement(), + _CompactAllButLatestChatMiddleware(), + ] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + await chat_client_base.get_response(messages) + + assert messages[0].message_id == "summary_1" + assert messages[2].text == "tool output" + assert messages[2].additional_properties[EXCLUDED_KEY] is True + annotation = messages[2].additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_1" + + +async def test_chat_middleware_rejects_partial_replacement_coverage( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, +) -> None: + class _SplitToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role != "tool": + rewritten.append(message) + continue + rewritten.extend(( + context.record_message_replacement( + Message(role="tool", contents=["tool fragment"]), + message, + ), + context.record_message_replacement( + Message(role="user", contents=["promoted fragment"]), + message, + ), + )) + context.messages = rewritten + await call_next() + + class _CompactOnlyFirstFragment(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + summarized = messages[0] + assert summarized.message_id is not None + summary = Message( + role="user", + contents=[""], + message_id="summary_1", + additional_properties={GROUP_ANNOTATION_KEY: {SUMMARY_OF_MESSAGE_IDS_KEY: [summarized.message_id]}}, + ) + annotation = summarized.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = summary.message_id + summarized.additional_properties[EXCLUDED_KEY] = True + context.messages = [summary, *messages[1:]] + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _SplitToolMessage(), + _CompactOnlyFirstFragment(), + ] + messages = [ + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + await chat_client_base.get_response(messages) + + assert [message.text for message in messages] == ["tool output", "q2"] + assert all(message.additional_properties.get(EXCLUDED_KEY) is not True for message in messages) + assert any(record.getMessage().startswith("Rejected 1 compaction summary message") for record in caplog.records) + + +async def test_chat_middleware_accepts_nested_complete_replacement_coverage( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, +) -> None: + class _SplitToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + source = context.messages[0] + context.messages = [ + context.record_message_replacement(Message(role="tool", contents=["tool fragment"]), source), + context.record_message_replacement(Message(role="user", contents=["promoted fragment"]), source), + *context.messages[1:], + ] + await call_next() + + class _NestedCompaction(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + first_replacement, second_replacement = messages[:2] + assert first_replacement.message_id is not None + assert second_replacement.message_id is not None + inner_summaries = [ + Message( + role="user", + contents=[f""], + message_id=f"summary_inner_{index}", + additional_properties={ + GROUP_ANNOTATION_KEY: {SUMMARY_OF_MESSAGE_IDS_KEY: [replacement.message_id]} + }, + ) + for index, replacement in enumerate((first_replacement, second_replacement), start=1) + ] + for replacement, summary in zip((first_replacement, second_replacement), inner_summaries, strict=True): + annotation = replacement.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = summary.message_id + replacement.additional_properties[EXCLUDED_KEY] = True + + outer_summary = Message( + role="user", + contents=[""], + message_id="summary_outer", + additional_properties={ + GROUP_ANNOTATION_KEY: { + SUMMARY_OF_MESSAGE_IDS_KEY: [summary.message_id for summary in inner_summaries] + } + }, + ) + for summary in inner_summaries: + annotation = summary.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = outer_summary.message_id + summary.additional_properties[EXCLUDED_KEY] = True + + context.messages = [outer_summary, *inner_summaries, *messages[2:]] + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _SplitToolMessage(), + _NestedCompaction(), + ] + messages = [ + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + await chat_client_base.get_response(messages) + + assert [message.text for message in messages] == ["", "tool output", "q2"] + assert messages[1].additional_properties[EXCLUDED_KEY] is True + annotation = messages[1].additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_outer" + assert not any(record.getMessage().startswith("Rejected ") for record in caplog.records) + + +async def test_chat_middleware_rejects_duplicate_summary_ids( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, +) -> None: + class _DuplicateSummaryIds(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + summarized = messages[0] + assert summarized.message_id is not None + summary_properties = {GROUP_ANNOTATION_KEY: {SUMMARY_OF_MESSAGE_IDS_KEY: [summarized.message_id]}} + summaries = [ + Message( + role="user", + contents=[f""], + message_id="summary_1", + additional_properties=summary_properties, + ) + for index in range(2) + ] + annotation = summarized.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = "summary_1" + summarized.additional_properties[EXCLUDED_KEY] = True + context.messages = [*summaries, *messages[1:]] + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [_DuplicateSummaryIds()] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + await chat_client_base.get_response(messages) + + assert [message.text for message in messages] == ["q1", "q2"] + assert messages[0].additional_properties.get(EXCLUDED_KEY) is not True + assert any(record.getMessage().startswith("Rejected 2 compaction summary message") for record in caplog.records) + + +@pytest.mark.parametrize("restore_in_place", [False, True], ids=["rebind", "in-place"]) +async def test_chat_middleware_reconciles_recorded_replacement_before_termination_and_restore( + chat_client_base: SupportsChatGetResponse, + restore_in_place: bool, +) -> None: + class _RestoreAfterCall(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + original_messages = tuple(context.messages) + await call_next() + if restore_in_place: + assert isinstance(context.messages, list) + context.messages[:] = original_messages + else: + context.messages = original_messages + + class _ReplaceToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role == "tool": + rewritten.append( + context.record_message_replacement( + Message(role="tool", contents=["replacement"]), + message, + ) + ) + else: + rewritten.append(message) + context.messages = rewritten + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _RestoreAfterCall(), + _ReplaceToolMessage(), + _CompactAllButLatestChatMiddleware(terminate=True), + ] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + response = await chat_client_base.get_response(messages) + + assert response.text == "terminated" + assert messages[0].message_id == "summary_1" + assert messages[2].text == "tool output" + assert messages[2].additional_properties[EXCLUDED_KEY] is True + annotation = messages[2].additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_1" + + @pytest.mark.parametrize("continue_pipeline", [True, False], ids=["call-next", "terminate"]) async def test_chat_middleware_reconciles_compaction_before_downstream( chat_client_base: SupportsChatGetResponse, diff --git a/python/packages/core/tests/core/test_middleware.py b/python/packages/core/tests/core/test_middleware.py index 264d13b87a7..c3e59903317 100644 --- a/python/packages/core/tests/core/test_middleware.py +++ b/python/packages/core/tests/core/test_middleware.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import AsyncIterable, Awaitable, Callable -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock import pytest @@ -131,6 +131,26 @@ def test_init_with_custom_values(self, mock_chat_client: Any) -> None: assert context.stream is True assert context.metadata == metadata + def test_record_message_replacement(self, mock_chat_client: Any) -> None: + """Replacement provenance is validated, deduplicated, and returned.""" + source = Message(role="tool", contents=["source"]) + replacement = Message(role="user", contents=["replacement"]) + context = ChatContext(client=mock_chat_client, messages=[source], options={}) + + result = context.record_message_replacement(replacement, [source, source]) + + assert result is replacement + assert context._message_replacements == [(replacement, (source,))] + + with pytest.raises(ValueError, match="at least one"): + context.record_message_replacement(replacement, []) + with pytest.raises(ValueError, match="cannot also be"): + context.record_message_replacement(replacement, replacement) + with pytest.raises(TypeError, match="replacement must be"): + context.record_message_replacement(cast(Any, "replacement"), source) + with pytest.raises(TypeError, match="only Message"): + context.record_message_replacement(replacement, [cast(Any, "source")]) + class TestAgentMiddlewarePipeline: """Test cases for AgentMiddlewarePipeline.""" From 17a733bc35dc606e3df2666545446542c03ae147 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 24 Sep 2026 11:44:46 +0200 Subject: [PATCH 42/42] Python: Protect custom AG-UI approval state namespaces --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 3 + .../ag-ui/tests/ag_ui/test_endpoint.py | 104 ++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 9fbb15f8d65..c70800c5b8e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -36,6 +36,7 @@ MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY, Message, SupportsAgentRun, + ToolApprovalMiddleware, WorkflowAgent, ) from agent_framework._middleware import ( @@ -2449,12 +2450,14 @@ def _a2ui_existing_tool_names(agent: SupportsAgentRun, tools: list[Any] | None) def _request_state_protected_keys(agent: SupportsAgentRun) -> set[str]: """Return session-state namespaces that client Shared State cannot own.""" context_providers = cast(list[Any], getattr(agent, "context_providers", [])) + agent_middleware = categorize_middleware(getattr(agent, "middleware", None))["agent"] return { _TOOL_APPROVAL_STATE_KEY, InMemoryHistoryProvider.DEFAULT_SOURCE_ID, MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY, *(provider.source_id for provider in context_providers), *_provider_service_session_state_keys(agent), + *(middleware.source_id for middleware in agent_middleware if isinstance(middleware, ToolApprovalMiddleware)), } diff --git a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py index 69bf104ec8a..c10e20fcfc3 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py +++ b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py @@ -36,6 +36,7 @@ InMemoryCheckpointStorage, InMemoryHistoryProvider, Message, + MiddlewareBundle, MiddlewareFailure, SessionContext, SupportsAgentRun, @@ -3829,6 +3830,109 @@ async def stream_fn( return TestClient(app), executed, messages_received, state, wrapped_agent +@pytest.mark.parametrize("middleware_kind", ["direct", "bundle", "wrapper"]) +@pytest.mark.parametrize("state_origin", ["request", "snapshot"]) +@pytest.mark.parametrize("trusted_approval", [False, True]) +async def test_endpoint_custom_approval_namespaces_remain_server_owned( + streaming_chat_client_stub: Any, + middleware_kind: str, + state_origin: str, + trusted_approval: bool, +) -> None: + """Shared State cannot supply or replace approval rules, including after snapshot restore.""" + executed: list[str] = [] + + def gated_tool() -> str: + executed.append("ran") + return "done" + + async def stream_fn( + messages: list[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> AsyncIterator[ChatResponseUpdate]: + if executed: + yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + else: + yield ChatResponseUpdate( + contents=[Content.from_function_call(call_id="call_gated", name="gated_tool", arguments="{}")], + role="assistant", + ) + + source_ids = ("custom_approvals", "other_approvals") + approval = ToolApprovalMiddleware(source_id="initial_approvals") + middleware = [approval, ToolApprovalMiddleware(source_id=source_ids[1])] + chat_client = streaming_chat_client_stub(stream_fn) + agent = Agent( + client=chat_client, + tools=[ + FunctionTool(name="gated_tool", description="Test tool", func=gated_tool, approval_mode="always_require") + ], + middleware=[MiddlewareBundle(middleware)] if middleware_kind == "bundle" else middleware, + ) + + class ForwardingAgent: + def __getattr__(self, name: str) -> Any: + return getattr(agent, name) + + wrapped = AgentFrameworkAgent( + agent=cast(SupportsAgentRun, ForwardingAgent()) if middleware_kind == "wrapper" else agent, + require_confirmation=False, + ) + # Namespace discovery must use the live middleware, not construction-time configuration. + approval.source_id = source_ids[0] + rule = {"tool_name": "gated_tool"} + shared_state = { + **{source_id: {"rules": [] if trusted_approval else [rule]} for source_id in source_ids}, + "client_value": "available", + } + store = InMemoryAGUIThreadSnapshotStore() + await store.save( + scope="test", + thread_id="thread-approval-state", + snapshot=AGUIThreadSnapshot( + state=shared_state if state_origin == "snapshot" else None, + session_state={source_id: {"rules": [rule]} for source_id in source_ids} if trusted_approval else None, + ), + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint( + app, + wrapped, + path="/approval", + snapshot_store=store, + snapshot_scope_resolver=lambda _request: "test", + ) + payload: dict[str, Any] = { + "threadId": "thread-approval-state", + "runId": "run-approval-state", + "messages": [{"role": "user", "content": "Run the test tool"}], + } + if state_origin == "request": + payload["state"] = shared_state + response = TestClient(app).post("/approval", json=payload) + + assert response.status_code == 200 + events = _decode_sse_events(response) + assert not any(event["type"] == "RUN_ERROR" for event in events) + finished = next(event for event in events if event["type"] == "RUN_FINISHED") + assert executed == (["ran"] if trusted_approval else []) + if trusted_approval: + assert "outcome" not in finished + else: + assert finished["outcome"]["type"] == "interrupt" + assert len(finished["outcome"]["interrupts"]) == 1 + assert chat_client.last_session is not None + assert chat_client.last_session.state["client_value"] == "available" + snapshot = await store.get(scope="test", thread_id="thread-approval-state") + assert snapshot is not None + assert snapshot.session_state is not None + for source_id in source_ids: + assert [stored_rule["tool_name"] for stored_rule in snapshot.session_state[source_id]["rules"]] == ( + ["gated_tool"] if trusted_approval else [] + ) + + def _build_tool_approval_auto_endpoint( streaming_chat_client_stub: Any, ) -> tuple[TestClient, list[str], list[Message], dict[str, str]]: