From 99468d3772d3139bd095e1fc2928706a85860119 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 2 Sep 2026 13:09:20 -0500 Subject: [PATCH 1/9] Add pluggable Agent application APIs --- README.md | 7 + azure/functions/__init__.py | 6 +- azure/functions/decorators/__init__.py | 4 +- azure/functions/decorators/function_app.py | 88 +++++++++++++ docs/ProgModelSpec.pyi | 37 +++++- tests/decorators/test_agents.py | 144 +++++++++++++++++++++ 6 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 tests/decorators/test_agents.py diff --git a/README.md b/README.md index efc6c3f4..07048fb6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ _What's available?_ - Triggers / Bindings : Blob, Cosmos DB, Event Grid, Event Hub, HTTP, Kafka, MySQL, Queue, ServiceBus, SQL, Timer, and Warmup - Create a Python Function on Linux using a custom docker image - Triggers / Bindings : Custom binding support +- Pluggable markdown Agent injection through provider extension packages + +Agent APIs are provider-neutral and add no binding metadata. Install a provider +package such as `azurefunctions-extensions-agents-framework`, then use +`FunctionApp.markdown_agent(provider=...)`, `AiApp`, or `DurableAiApp`. Durable +support is installed through the provider package's `[durable]` extra and is +not imported by the core SDK. #### Get Started diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index e267d450..19ecdf0a 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -7,7 +7,7 @@ from ._eventgrid import CloudEvent, EventGridEvent, EventGridOutputEvent from ._cosmosdb import Document, DocumentList from ._http import HttpRequest, HttpResponse -from .decorators import (FunctionApp, Function, Blueprint, +from .decorators import (AiApp, DurableAiApp, FunctionApp, Function, Blueprint, DecoratorApi, DataType, AuthLevel, Cardinality, AccessRights, HttpMethod, AsgiFunctionApp, WsgiFunctionApp, @@ -94,6 +94,8 @@ # PyStein implementation 'FunctionApp', + 'AiApp', + 'DurableAiApp', 'Function', 'FunctionRegister', 'DecoratorApi', @@ -117,4 +119,4 @@ 'mcp_content', ) -__version__ = '2.3.0' +__version__ = '2.4.0b1' diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index beaf7ff6..64d75a5c 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. from .core import Cardinality, AccessRights, CosmosDBChangeFeedMode -from .function_app import FunctionApp, Function, DecoratorApi, DataType, \ +from .function_app import AiApp, DurableAiApp, FunctionApp, Function, DecoratorApi, DataType, \ AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \ WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \ SettingsApi, BlobSource, McpPropertyType @@ -10,6 +10,8 @@ __all__ = [ 'FunctionApp', + 'AiApp', + 'DurableAiApp', 'Function', 'FunctionRegister', 'DecoratorApi', diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index fbe241b4..9e3195ee 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -4,6 +4,7 @@ import asyncio import dataclasses import functools +import importlib import inspect import json import logging @@ -64,6 +65,31 @@ MySqlTrigger +def _agent_provider_distribution(provider: str) -> str: + normalized = provider.replace('_', '-') + if normalized.startswith('agent-'): + normalized = normalized.removeprefix('agent-') + return f'azurefunctions-extensions-agents-{normalized}' + + +def _load_agents_base(provider: str): + try: + return importlib.import_module('azurefunctions.extensions.agents_base') + except ModuleNotFoundError as exc: + missing_base_modules = { + 'azurefunctions', + 'azurefunctions.extensions', + 'azurefunctions.extensions.agents_base', + } + if exc.name not in missing_base_modules: + raise + distribution = _agent_provider_distribution(provider) + raise ImportError( + f"Agent provider {provider!r} is not installed. " + f"Install {distribution!r}." + ) from exc + + class Function(object): """ The function object represents a function in Function App. It @@ -4540,6 +4566,68 @@ def __init__(self, """ super().__init__(auth_level=http_auth_level) + def markdown_agent(self, *, provider: str, **kwargs): + """Inject a provider Agent built from a markdown definition.""" + agents_base = _load_agents_base(provider) + return agents_base.markdown_agent(self, provider=provider, **kwargs) + + +class AiApp(FunctionApp): + """FunctionApp configured for one pluggable Agent provider.""" + + def __init__(self, + http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION, + *, provider: str, app_root=None, **provider_options): + super().__init__(http_auth_level=http_auth_level) + self._agent_provider = provider + agents_base = _load_agents_base(provider) + agents_base.configure_app( + self, + provider=provider, + app_root=app_root, + provider_options=provider_options, + ) + + def markdown_agent(self, *, provider: Optional[str] = None, **kwargs): + selected_provider = provider or self._agent_provider + return super().markdown_agent(provider=selected_provider, **kwargs) + + +class DurableAiApp(AiApp): + """AiApp with optional replay-safe Durable Agent orchestration.""" + + def __init__(self, + http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION, + *, provider: str, app_root=None, **provider_options): + super().__init__( + http_auth_level=http_auth_level, + provider=provider, + app_root=app_root, + **provider_options, + ) + try: + _load_agents_base(provider).configure_durable_app(self) + except ModuleNotFoundError as exc: + if exc.name != 'azure.durable_functions': + raise + distribution = _agent_provider_distribution(provider) + raise ImportError( + f"Durable Agent support is not installed. " + f"Install {distribution + '[durable]'!r}." + ) from exc + + def orchestration_trigger(self, context_name: str, + orchestration: Optional[str] = None, + input_type: Optional[type] = None): + agents_base = _load_agents_base(self._agent_provider) + return agents_base.durable_orchestration_trigger( + self, + sdk_decorator=super().orchestration_trigger, + context_name=context_name, + orchestration=orchestration, + input_type=input_type, + ) + class Blueprint(TriggerApi, BindingApi, SettingsApi): """ diff --git a/docs/ProgModelSpec.pyi b/docs/ProgModelSpec.pyi index b9c1d60a..fbcc9466 100644 --- a/docs/ProgModelSpec.pyi +++ b/docs/ProgModelSpec.pyi @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. from abc import ABC -from typing import Callable, Dict, List, Optional, Union, Iterable +from typing import Any, Callable, Dict, List, Optional, Union, Iterable from azure.functions import AsgiMiddleware, WsgiMiddleware from azure.functions.decorators.core import Binding, BlobSource, Trigger, DataType, \ @@ -1329,6 +1329,41 @@ class FunctionApp(FunctionRegister, TriggerApi, BindingApi): """ pass + def markdown_agent(self, *, provider: str, **kwargs: Any) -> Callable: + """Inject an Agent supplied by a provider extension. + + :param provider: Registered Agent provider ID. + :param kwargs: Provider and markdown binding options. + :return: Decorator function. + """ + pass + + +class AiApp(FunctionApp): + """FunctionApp configured for one Agent provider.""" + + def __init__(self, + http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION, + *, provider: str, app_root=None, + **provider_options: Any): + """Configure an app with a provider and immutable defaults.""" + pass + + def markdown_agent(self, *, provider: Optional[str] = None, + **kwargs: Any) -> Callable: + """Inject an Agent using the configured provider by default.""" + pass + + +class DurableAiApp(AiApp): + """AiApp with optional replay-safe Durable Agent orchestration.""" + + def orchestration_trigger(self, context_name: str, + orchestration: Optional[str] = None, + input_type: Optional[type] = None) -> Callable: + """Register an orchestrator with a Durable Agent context.""" + pass + class BluePrint(TriggerApi, BindingApi, SettingsApi): """Functions container class where all the functions diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py new file mode 100644 index 00000000..bd5e0cd8 --- /dev/null +++ b/tests/decorators/test_agents.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest +from unittest.mock import patch + +import azure.functions as func +from azure.functions.decorators.function_app import ( + AiApp, + DurableAiApp, + FunctionApp, +) + + +class TestAgentApps(unittest.TestCase): + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_function_app_markdown_agent_delegates_exact_arguments( + self, load_agents_base): + agents_base = load_agents_base.return_value + decorator = object() + agents_base.markdown_agent.return_value = decorator + app = FunctionApp() + + result = app.markdown_agent( + provider='agent_framework', + arg_name='agent', + agent_name='orders', + client_factory='factory', + ) + + self.assertIs(result, decorator) + agents_base.markdown_agent.assert_called_once_with( + app, + provider='agent_framework', + arg_name='agent', + agent_name='orders', + client_factory='factory', + ) + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_ai_app_configures_provider_and_defaults(self, load_agents_base): + agents_base = load_agents_base.return_value + + app = AiApp( + provider='agent_framework', + app_root='app', + client_factory='factory', + ) + + agents_base.configure_app.assert_called_once_with( + app, + provider='agent_framework', + app_root='app', + provider_options={'client_factory': 'factory'}, + ) + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_ai_app_markdown_agent_uses_configured_provider( + self, load_agents_base): + agents_base = load_agents_base.return_value + app = AiApp(provider='agent_framework') + agents_base.reset_mock() + + app.markdown_agent(arg_name='agent', agent_name='orders') + + agents_base.markdown_agent.assert_called_once_with( + app, + provider='agent_framework', + arg_name='agent', + agent_name='orders', + ) + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_durable_ai_app_configures_durable_support( + self, load_agents_base): + agents_base = load_agents_base.return_value + + app = DurableAiApp(provider='agent_framework') + + agents_base.configure_durable_app.assert_called_once_with(app) + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_durable_orchestration_delegates_to_base( + self, load_agents_base): + agents_base = load_agents_base.return_value + sentinel = object() + agents_base.durable_orchestration_trigger.return_value = sentinel + app = DurableAiApp(provider='agent_framework') + + result = app.orchestration_trigger( + context_name='context', + orchestration='orders', + ) + + self.assertIs(result, sentinel) + call = agents_base.durable_orchestration_trigger.call_args + self.assertIs(call.args[0], app) + self.assertEqual(call.kwargs['context_name'], 'context') + self.assertEqual(call.kwargs['orchestration'], 'orders') + self.assertIsNone(call.kwargs['input_type']) + self.assertTrue(callable(call.kwargs['sdk_decorator'])) + + def test_agent_apps_are_public(self): + self.assertIs(func.AiApp, AiApp) + self.assertIs(func.DurableAiApp, DurableAiApp) + + @patch('azure.functions.decorators.function_app.importlib.import_module') + def test_missing_base_reports_provider_install(self, import_module): + import_module.side_effect = ModuleNotFoundError( + "No module named 'azurefunctions.extensions.agents_base'", + name='azurefunctions.extensions.agents_base', + ) + + with self.assertRaisesRegex( + ImportError, + 'azurefunctions-extensions-agents-framework'): + FunctionApp().markdown_agent(provider='agent_framework') + + @patch('azure.functions.decorators.function_app.importlib.import_module') + def test_provider_import_error_is_not_rewritten(self, import_module): + import_module.side_effect = ModuleNotFoundError( + "No module named 'provider_dependency'", + name='provider_dependency', + ) + + with self.assertRaisesRegex(ModuleNotFoundError, 'provider_dependency'): + FunctionApp().markdown_agent(provider='agent_framework') + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_missing_durable_reports_provider_extra(self, load_agents_base): + agents_base = load_agents_base.return_value + agents_base.configure_durable_app.side_effect = ModuleNotFoundError( + "No module named 'azure.durable_functions'", + name='azure.durable_functions', + ) + + with self.assertRaisesRegex( + ImportError, + r'azurefunctions-extensions-agents-framework\[durable\]'): + DurableAiApp(provider='agent_framework') + + +if __name__ == '__main__': + unittest.main() From ad4caaf7687c88f6fe2269828c07a16b983047c2 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 2 Sep 2026 13:15:23 -0500 Subject: [PATCH 2/9] remove version bump --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 19ecdf0a..caca2775 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -119,4 +119,4 @@ 'mcp_content', ) -__version__ = '2.4.0b1' +__version__ = '2.3.0' From 03a3fa746aaae9b42eaf84ec4d3fa6f29b798e12 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:38:55 -0500 Subject: [PATCH 3/9] Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- azure/functions/decorators/function_app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 9e3195ee..893cbacd 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -4566,7 +4566,8 @@ def __init__(self, """ super().__init__(auth_level=http_auth_level) - def markdown_agent(self, *, provider: str, **kwargs): + def markdown_agent(self, *, provider: str, + **kwargs: Any) -> Callable[..., Any]: """Inject a provider Agent built from a markdown definition.""" agents_base = _load_agents_base(provider) return agents_base.markdown_agent(self, provider=provider, **kwargs) @@ -4588,8 +4589,9 @@ def __init__(self, provider_options=provider_options, ) - def markdown_agent(self, *, provider: Optional[str] = None, **kwargs): - selected_provider = provider or self._agent_provider + def markdown_agent(self, *, provider: Optional[str] = None, + **kwargs: Any) -> Callable[..., Any]: + selected_provider = self._agent_provider if provider is None else provider return super().markdown_agent(provider=selected_provider, **kwargs) From aa856ada78445cb4d716fd40f3450dcb4f1f0617 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 2 Sep 2026 14:52:33 -0500 Subject: [PATCH 4/9] rename --- azure/functions/decorators/function_app.py | 5 +++-- tests/decorators/test_agents.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 9e3195ee..bd3eb231 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -74,12 +74,13 @@ def _agent_provider_distribution(provider: str) -> str: def _load_agents_base(provider: str): try: - return importlib.import_module('azurefunctions.extensions.agents_base') + return importlib.import_module('azurefunctions.extensions.agents.base') except ModuleNotFoundError as exc: missing_base_modules = { 'azurefunctions', 'azurefunctions.extensions', - 'azurefunctions.extensions.agents_base', + 'azurefunctions.extensions.agents', + 'azurefunctions.extensions.agents.base', } if exc.name not in missing_base_modules: raise diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py index bd5e0cd8..1db778a7 100644 --- a/tests/decorators/test_agents.py +++ b/tests/decorators/test_agents.py @@ -107,8 +107,8 @@ def test_agent_apps_are_public(self): @patch('azure.functions.decorators.function_app.importlib.import_module') def test_missing_base_reports_provider_install(self, import_module): import_module.side_effect = ModuleNotFoundError( - "No module named 'azurefunctions.extensions.agents_base'", - name='azurefunctions.extensions.agents_base', + "No module named 'azurefunctions.extensions.agents.base'", + name='azurefunctions.extensions.agents.base', ) with self.assertRaisesRegex( From 8aadfd81ad8ac4668bd89900383c7c3ecd62abf6 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 10:01:35 -0500 Subject: [PATCH 5/9] per agent provider --- README.md | 4 ++- azure/functions/decorators/function_app.py | 11 +++++++ tests/decorators/test_agents.py | 38 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 07048fb6..832884b6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ Agent APIs are provider-neutral and add no binding metadata. Install a provider package such as `azurefunctions-extensions-agents-framework`, then use `FunctionApp.markdown_agent(provider=...)`, `AiApp`, or `DurableAiApp`. Durable support is installed through the provider package's `[durable]` extra and is -not imported by the core SDK. +not imported by the core SDK. Each Agent binding may select a different +installed provider; `AiApp` supplies a default. Configure reusable defaults for +additional providers with `FunctionApp.configure_agent_provider()`. #### Get Started diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 5697c999..6c67620a 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -4573,6 +4573,17 @@ def markdown_agent(self, *, provider: str, agents_base = _load_agents_base(provider) return agents_base.markdown_agent(self, provider=provider, **kwargs) + def configure_agent_provider(self, *, provider: str, app_root=None, + **provider_options: Any) -> None: + """Configure an Agent provider for reuse, including Durable calls.""" + agents_base = _load_agents_base(provider) + agents_base.configure_agent_provider( + self, + provider=provider, + app_root=app_root, + provider_options=provider_options, + ) + class AiApp(FunctionApp): """FunctionApp configured for one pluggable Agent provider.""" diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py index 1db778a7..9635d782 100644 --- a/tests/decorators/test_agents.py +++ b/tests/decorators/test_agents.py @@ -70,6 +70,44 @@ def test_ai_app_markdown_agent_uses_configured_provider( agent_name='orders', ) + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_ai_app_markdown_agent_can_override_configured_provider( + self, load_agents_base): + agents_base = load_agents_base.return_value + app = AiApp(provider='agent_framework') + agents_base.reset_mock() + + app.markdown_agent( + provider='langgraph', + arg_name='agent', + agent_name='researcher', + ) + + agents_base.markdown_agent.assert_called_once_with( + app, + provider='langgraph', + arg_name='agent', + agent_name='researcher', + ) + + @patch('azure.functions.decorators.function_app._load_agents_base') + def test_configure_agent_provider_delegates_defaults(self, load_agents_base): + agents_base = load_agents_base.return_value + app = FunctionApp() + + app.configure_agent_provider( + provider='langgraph', + app_root='app', + recursion_limit=10, + ) + + agents_base.configure_agent_provider.assert_called_once_with( + app, + provider='langgraph', + app_root='app', + provider_options={'recursion_limit': 10}, + ) + @patch('azure.functions.decorators.function_app._load_agents_base') def test_durable_ai_app_configures_durable_support( self, load_agents_base): From 3c6cd09a489ecdb17504be514aa4b61f00b7993e Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 10:05:20 -0500 Subject: [PATCH 6/9] refactoring --- azure/functions/decorators/_agents.py | 29 ++++++++++++++++++++++ azure/functions/decorators/function_app.py | 28 +-------------------- tests/decorators/test_agents.py | 4 +-- 3 files changed, 32 insertions(+), 29 deletions(-) create mode 100644 azure/functions/decorators/_agents.py diff --git a/azure/functions/decorators/_agents.py b/azure/functions/decorators/_agents.py new file mode 100644 index 00000000..b2bbc7f1 --- /dev/null +++ b/azure/functions/decorators/_agents.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import importlib + + +def _agent_provider_distribution(provider: str) -> str: + normalized = provider.replace('_', '-') + if normalized.startswith('agent-'): + normalized = normalized.removeprefix('agent-') + return f'azurefunctions-extensions-agents-{normalized}' + + +def _load_agents_base(provider: str): + try: + return importlib.import_module('azurefunctions.extensions.agents.base') + except ModuleNotFoundError as exc: + missing_base_modules = { + 'azurefunctions', + 'azurefunctions.extensions', + 'azurefunctions.extensions.agents', + 'azurefunctions.extensions.agents.base', + } + if exc.name not in missing_base_modules: + raise + distribution = _agent_provider_distribution(provider) + raise ImportError( + f"Agent provider {provider!r} is not installed. " + f"Install {distribution!r}." + ) from exc diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 6c67620a..76ce508a 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -4,7 +4,6 @@ import asyncio import dataclasses import functools -import importlib import inspect import json import logging @@ -63,32 +62,7 @@ from .._http_wsgi import WsgiMiddleware, Context from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ MySqlTrigger - - -def _agent_provider_distribution(provider: str) -> str: - normalized = provider.replace('_', '-') - if normalized.startswith('agent-'): - normalized = normalized.removeprefix('agent-') - return f'azurefunctions-extensions-agents-{normalized}' - - -def _load_agents_base(provider: str): - try: - return importlib.import_module('azurefunctions.extensions.agents.base') - except ModuleNotFoundError as exc: - missing_base_modules = { - 'azurefunctions', - 'azurefunctions.extensions', - 'azurefunctions.extensions.agents', - 'azurefunctions.extensions.agents.base', - } - if exc.name not in missing_base_modules: - raise - distribution = _agent_provider_distribution(provider) - raise ImportError( - f"Agent provider {provider!r} is not installed. " - f"Install {distribution!r}." - ) from exc +from ._agents import _agent_provider_distribution, _load_agents_base class Function(object): diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py index 9635d782..b3fc226d 100644 --- a/tests/decorators/test_agents.py +++ b/tests/decorators/test_agents.py @@ -142,7 +142,7 @@ def test_agent_apps_are_public(self): self.assertIs(func.AiApp, AiApp) self.assertIs(func.DurableAiApp, DurableAiApp) - @patch('azure.functions.decorators.function_app.importlib.import_module') + @patch('azure.functions.decorators._agents.importlib.import_module') def test_missing_base_reports_provider_install(self, import_module): import_module.side_effect = ModuleNotFoundError( "No module named 'azurefunctions.extensions.agents.base'", @@ -154,7 +154,7 @@ def test_missing_base_reports_provider_install(self, import_module): 'azurefunctions-extensions-agents-framework'): FunctionApp().markdown_agent(provider='agent_framework') - @patch('azure.functions.decorators.function_app.importlib.import_module') + @patch('azure.functions.decorators._agents.importlib.import_module') def test_provider_import_error_is_not_rewritten(self, import_module): import_module.side_effect = ModuleNotFoundError( "No module named 'provider_dependency'", From 9a9db8104c0837d54ed96f014de1ec4fd28d8e1c Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 10:26:47 -0500 Subject: [PATCH 7/9] v1 for durable --- README.md | 4 +-- azure/functions/decorators/_agents.py | 12 +++------ azure/functions/decorators/function_app.py | 18 +++---------- tests/decorators/test_agents.py | 30 +++++++++------------- 4 files changed, 20 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 832884b6..10c8688a 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ package such as `azurefunctions-extensions-agents-framework`, then use `FunctionApp.markdown_agent(provider=...)`, `AiApp`, or `DurableAiApp`. Durable support is installed through the provider package's `[durable]` extra and is not imported by the core SDK. Each Agent binding may select a different -installed provider; `AiApp` supplies a default. Configure reusable defaults for -additional providers with `FunctionApp.configure_agent_provider()`. +installed provider; `AiApp` supplies a default, which all Durable Agent calls +use. #### Get Started diff --git a/azure/functions/decorators/_agents.py b/azure/functions/decorators/_agents.py index b2bbc7f1..6b312bee 100644 --- a/azure/functions/decorators/_agents.py +++ b/azure/functions/decorators/_agents.py @@ -2,6 +2,8 @@ # Licensed under the MIT License. import importlib +_AGENTS_BASE_MODULE = 'azurefunctions.extensions.agents.base' + def _agent_provider_distribution(provider: str) -> str: normalized = provider.replace('_', '-') @@ -12,16 +14,8 @@ def _agent_provider_distribution(provider: str) -> str: def _load_agents_base(provider: str): try: - return importlib.import_module('azurefunctions.extensions.agents.base') + return importlib.import_module(_AGENTS_BASE_MODULE) except ModuleNotFoundError as exc: - missing_base_modules = { - 'azurefunctions', - 'azurefunctions.extensions', - 'azurefunctions.extensions.agents', - 'azurefunctions.extensions.agents.base', - } - if exc.name not in missing_base_modules: - raise distribution = _agent_provider_distribution(provider) raise ImportError( f"Agent provider {provider!r} is not installed. " diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 76ce508a..d5c522b0 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -11,7 +11,7 @@ from abc import ABC from datetime import time -from typing import Any, Callable, Dict, List, Optional, Union, \ +from typing import Any, Callable, cast, Dict, List, Optional, Union, \ Iterable from azure.functions.decorators.blob import BlobTrigger, BlobInput, BlobOutput @@ -4545,18 +4545,8 @@ def markdown_agent(self, *, provider: str, **kwargs: Any) -> Callable[..., Any]: """Inject a provider Agent built from a markdown definition.""" agents_base = _load_agents_base(provider) - return agents_base.markdown_agent(self, provider=provider, **kwargs) - - def configure_agent_provider(self, *, provider: str, app_root=None, - **provider_options: Any) -> None: - """Configure an Agent provider for reuse, including Durable calls.""" - agents_base = _load_agents_base(provider) - agents_base.configure_agent_provider( - self, - provider=provider, - app_root=app_root, - provider_options=provider_options, - ) + return cast(Callable[..., Any], agents_base.markdown_agent( + self, provider=provider, **kwargs)) class AiApp(FunctionApp): @@ -4596,8 +4586,6 @@ def __init__(self, try: _load_agents_base(provider).configure_durable_app(self) except ModuleNotFoundError as exc: - if exc.name != 'azure.durable_functions': - raise distribution = _agent_provider_distribution(provider) raise ImportError( f"Durable Agent support is not installed. " diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py index b3fc226d..d613f1bc 100644 --- a/tests/decorators/test_agents.py +++ b/tests/decorators/test_agents.py @@ -90,24 +90,6 @@ def test_ai_app_markdown_agent_can_override_configured_provider( agent_name='researcher', ) - @patch('azure.functions.decorators.function_app._load_agents_base') - def test_configure_agent_provider_delegates_defaults(self, load_agents_base): - agents_base = load_agents_base.return_value - app = FunctionApp() - - app.configure_agent_provider( - provider='langgraph', - app_root='app', - recursion_limit=10, - ) - - agents_base.configure_agent_provider.assert_called_once_with( - app, - provider='langgraph', - app_root='app', - provider_options={'recursion_limit': 10}, - ) - @patch('azure.functions.decorators.function_app._load_agents_base') def test_durable_ai_app_configures_durable_support( self, load_agents_base): @@ -154,6 +136,18 @@ def test_missing_base_reports_provider_install(self, import_module): 'azurefunctions-extensions-agents-framework'): FunctionApp().markdown_agent(provider='agent_framework') + @patch('azure.functions.decorators._agents.importlib.import_module') + def test_missing_base_parent_reports_provider_install(self, import_module): + import_module.side_effect = ModuleNotFoundError( + "No module named 'azurefunctions'", + name='azurefunctions', + ) + + with self.assertRaisesRegex( + ImportError, + 'azurefunctions-extensions-agents-framework'): + FunctionApp().markdown_agent(provider='agent_framework') + @patch('azure.functions.decorators._agents.importlib.import_module') def test_provider_import_error_is_not_rewritten(self, import_module): import_module.side_effect = ModuleNotFoundError( From eb8c11957fd4e515d001a60ec591990f8b673753 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 10:55:30 -0500 Subject: [PATCH 8/9] fix test --- tests/decorators/test_agents.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py index d613f1bc..5523eb9e 100644 --- a/tests/decorators/test_agents.py +++ b/tests/decorators/test_agents.py @@ -125,17 +125,22 @@ def test_agent_apps_are_public(self): self.assertIs(func.DurableAiApp, DurableAiApp) @patch('azure.functions.decorators._agents.importlib.import_module') - def test_missing_base_reports_provider_install(self, import_module): + def test_missing_provider_reports_extension_install(self, import_module): import_module.side_effect = ModuleNotFoundError( "No module named 'azurefunctions.extensions.agents.base'", name='azurefunctions.extensions.agents.base', ) - with self.assertRaisesRegex( - ImportError, - 'azurefunctions-extensions-agents-framework'): + with self.assertRaises(ImportError) as raised: FunctionApp().markdown_agent(provider='agent_framework') + self.assertEqual( + str(raised.exception), + "Agent provider 'agent_framework' is not installed. " + "Install 'azurefunctions-extensions-agents-framework'.", + ) + self.assertIs(raised.exception.__cause__, import_module.side_effect) + @patch('azure.functions.decorators._agents.importlib.import_module') def test_missing_base_parent_reports_provider_install(self, import_module): import_module.side_effect = ModuleNotFoundError( @@ -149,15 +154,19 @@ def test_missing_base_parent_reports_provider_install(self, import_module): FunctionApp().markdown_agent(provider='agent_framework') @patch('azure.functions.decorators._agents.importlib.import_module') - def test_provider_import_error_is_not_rewritten(self, import_module): + def test_provider_import_error_reports_extension_install(self, import_module): import_module.side_effect = ModuleNotFoundError( "No module named 'provider_dependency'", name='provider_dependency', ) - with self.assertRaisesRegex(ModuleNotFoundError, 'provider_dependency'): + with self.assertRaisesRegex( + ImportError, + 'azurefunctions-extensions-agents-framework') as raised: FunctionApp().markdown_agent(provider='agent_framework') + self.assertIs(raised.exception.__cause__, import_module.side_effect) + @patch('azure.functions.decorators.function_app._load_agents_base') def test_missing_durable_reports_provider_extra(self, load_agents_base): agents_base = load_agents_base.return_value From 0d032934e6defa89b76470edfb4fd9b3354eea0b Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 3 Sep 2026 15:00:37 -0500 Subject: [PATCH 9/9] simplify --- azure/functions/decorators/function_app.py | 6 ++---- tests/decorators/test_agents.py | 20 ++++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index d5c522b0..dc3a87d2 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -4565,10 +4565,8 @@ def __init__(self, provider_options=provider_options, ) - def markdown_agent(self, *, provider: Optional[str] = None, - **kwargs: Any) -> Callable[..., Any]: - selected_provider = self._agent_provider if provider is None else provider - return super().markdown_agent(provider=selected_provider, **kwargs) + def markdown_agent(self, **kwargs: Any) -> Callable[..., Any]: + return super().markdown_agent(provider=self._agent_provider, **kwargs) class DurableAiApp(AiApp): diff --git a/tests/decorators/test_agents.py b/tests/decorators/test_agents.py index 5523eb9e..b1ec2fbc 100644 --- a/tests/decorators/test_agents.py +++ b/tests/decorators/test_agents.py @@ -71,24 +71,20 @@ def test_ai_app_markdown_agent_uses_configured_provider( ) @patch('azure.functions.decorators.function_app._load_agents_base') - def test_ai_app_markdown_agent_can_override_configured_provider( + def test_ai_app_markdown_agent_rejects_provider_override( self, load_agents_base): agents_base = load_agents_base.return_value app = AiApp(provider='agent_framework') agents_base.reset_mock() - app.markdown_agent( - provider='langgraph', - arg_name='agent', - agent_name='researcher', - ) + with self.assertRaisesRegex(TypeError, "multiple values for keyword"): + app.markdown_agent( + provider='langgraph', + arg_name='agent', + agent_name='researcher', + ) - agents_base.markdown_agent.assert_called_once_with( - app, - provider='langgraph', - arg_name='agent', - agent_name='researcher', - ) + agents_base.markdown_agent.assert_not_called() @patch('azure.functions.decorators.function_app._load_agents_base') def test_durable_ai_app_configures_durable_support(