diff --git a/README.md b/README.md index efc6c3f4..10c8688a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ _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. Each Agent binding may select a different +installed provider; `AiApp` supplies a default, which all Durable Agent calls +use. #### Get Started diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index e267d450..caca2775 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', 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/_agents.py b/azure/functions/decorators/_agents.py new file mode 100644 index 00000000..6b312bee --- /dev/null +++ b/azure/functions/decorators/_agents.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import importlib + +_AGENTS_BASE_MODULE = 'azurefunctions.extensions.agents.base' + + +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(_AGENTS_BASE_MODULE) + except ModuleNotFoundError as exc: + 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 fbe241b4..dc3a87d2 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 @@ -62,6 +62,7 @@ from .._http_wsgi import WsgiMiddleware, Context from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ MySqlTrigger +from ._agents import _agent_provider_distribution, _load_agents_base class Function(object): @@ -4540,6 +4541,67 @@ def __init__(self, """ super().__init__(auth_level=http_auth_level) + 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 cast(Callable[..., Any], 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, **kwargs: Any) -> Callable[..., Any]: + return super().markdown_agent(provider=self._agent_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: + 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..b1ec2fbc --- /dev/null +++ b/tests/decorators/test_agents.py @@ -0,0 +1,181 @@ +# 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_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() + + with self.assertRaisesRegex(TypeError, "multiple values for keyword"): + app.markdown_agent( + 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( + 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._agents.importlib.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.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( + "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_reports_extension_install(self, import_module): + import_module.side_effect = ModuleNotFoundError( + "No module named 'provider_dependency'", + name='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 + 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()